From dab87878ccf17308f3bd9dc1b811459ff2ba327f Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 12:40:41 +0100 Subject: [PATCH] feat: download - fase 02 --- .../__pycache__/routes_files.cpython-313.pyc | Bin 6330 -> 6676 bytes webui/backend/app/api/routes_files.py | 12 +- .../file_ops_service.cpython-313.pyc | Bin 27125 -> 32021 bytes .../backend/app/services/file_ops_service.py | 134 ++++++++++++++---- .../test_api_download_golden.cpython-313.pyc | Bin 6810 -> 12924 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 35565 -> 35557 bytes .../tests/golden/test_api_download_golden.py | 71 ++++++++++ .../tests/golden/test_ui_smoke_golden.py | 12 +- webui/html/app.js | 31 ++-- 9 files changed, 215 insertions(+), 45 deletions(-) diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index c80d22240ade0cef0878e3fe599b977a97a34041..feb4169319f59ea63404e2e388ae313490a186ec 100644 GIT binary patch literal 6676 zcmcgwU2GHC6`udG<2ZI==g$ct)NA5NFeJ8n$4Qn5 zRayE#=*w=ks+vAEPZfFNrBeG;^{IWSWGj^tMvDaML$z;4L0a+FbMDOe4~MW)%Yg4a zbMLv|J?A^;{!Dh=?kWbJ+Oa>yuh%lnzwyO>WK&>w*93<7Eu%6jJHaR{VcSvWCOCyB zydn@m5s9csL{elTD-PmNoWx0W_=zgTMO;)CCaM)TaZ_2Gs8Kw`qj-r|sU@{a9jQ}% z#HaX)U#TbcN&{(78cCzlM4FUl(oDxl69J`#v`|@|XjKl71MN(h?_gAi>KtN8TexlN zC${N@TCEEERM!v}7CM-4%K&TkoMPJ;`=8pQRtK0n!X0+N8b;dpz;cJ{_rdB_Ys#<= z?SbV9d-uUQta{6^I`+V-4L4CN3D~H!eGKE9FaIW%^p&z9*jyB9HK_W_X77aA#|zBb zl3E{b#@SV08OK}!4JGx6+E9kry$51rxON|I`qZW}te!ovn)3|EZ(*RwR`sYFD8ugE z13RBjWu6yh7xny@+FFKqcn`z_d234}RD`JZt8Hay!9CCpz7tw|8QPIO&3A}s2W7)K{OR~qaxtwDLmtsm zS|X+!!f1S6gEC1L4Dqu?jodTjDeV`Fnw~bQrc?9DXbd#~tX54EExHg-%uQ)}3J|fe z=0bGl?i@)jCSq5j`rV+*aF1y7T3WL)YV22Yh~Y)F@x*M>1~IBXxf_cUTiK?qyfx5LgMu zy?=rP%8VnSEk`VNB-0eq1ICeFEZU*4_#mCwaM&&yu&31B^N-H2FaP2G@9#gE+a4Uv z4vwy!&$upTgp02UDuSz!2$28p-Bs$4;UwD3BGKbZmhkvKvFzzFbagxcYC9U$sxPCGoNkH=933kM#PW=*=o$J|yim>H zIA)V1qQ&BA@IXNHAaBOzB;c8r<8*n$Phm3_XuRG81vR`H4L|$n?E00pvl-VwMi{V` znzs(kj>G0!l6}Yq4M>sMd+bDDHk%K}CQGeDV-YjRnZ{Cvi*x7<;1eu{;8!q{?hbh~ zJ2FDYYl7Fu6=x`xTu$K0QetL4Z~h2MZ{IM{%mjBMADd}TwPXaq4CaZ49Gs#36He*A+twm!R z(T!@WC6d0E(l$AA7T}Dk1;~IXZGs{5S~Rhk3W|mo4j0_aBQ`h0h1xtU22xGO;U;aK zw2=0g$~0pPhcz%}IPBA?H2)1p9&!{$4Z}~5L1As--o|ZDZ`RZMIF|ABW;`cX$G-El z?>M~QG-n+xJKm=C=B&4K$Jeqxnf3L(lm)M}ItDwx!~5|5SNFde-GG{!eyw9r9}IhjYgsrpm*O!kS+b&#ZJL_D2I1{1s?s4@F|Mo~ zF_jzQ5`0lac`G}3m1f5@uZlEVc$G$4m4^f?`5lvnNTP{3%}RQsn!ZKM$dZr1TEnHo z&1zAP%)rr4LrcyuB}PV|D2t>> z0mvoj`3!rnLSbzQ7{=~AEcoB8Rg?WqX_A}iB^4V=u$Om!2i z6MM0k_p0M3*vX(o=pN@qT)I}9K{DW4o?avg0)Js+Y?Zna6%xi*IHU9Z?h5_*g%Yfy z*FuAboz}V=-byCt$p@%kRbdd`^MekPiksL^FJwQjpxKC;Qjuof1JgWDp?}3WAJ@}2 zz=oRzKTR#V;TO+G8F5yak7~YUKC7RO_g5HSkM zxQP)w1qmh;!2_8%u%Ol;Sw^j;+n>K*n0FIDAQ1T0No)OuyNP*OX|%p5`{C;ne(`>N z=C-yF)d>w5#)u$OgeC!ooX}Wfa0zt-?Isu&f{YtY#dtCLrwc7H6W4Ttw_>BaxZD21 z?JxhhCsv}Fe*qCvNeB&o)SBOWeJCBr;sO?!==4%cMp42em!2MU|D-zyT@;(lF7z<; zL&I>Kh1InEnl^)UE5e#ZOO2wUP{cto=ne&TWufZM1lQ;-ElYP zcod$yTg(<1B>7&+*tW|v+(3>)F;`Kzu6rhv>6tunp^`fz5{iomFd)g3KANcm}Q|qm*+w$2dv7fU$wf-EB!gG%= zC!zp`y6ST>6&*}XYmVx{T@}oCVJk8L=2L|_WFqiYVC~|@rPV-&{QyQcv^>qvA&czb zPL(^yL;bv_E+;|_Du9YTIR_)vy>fztm?v@^wt4fAnXXfaIZ(!KCj}JPPAg2ENNodB z+lerP$ZV&{23j_;#WuZYKql@K>=LV4|4x-J$3snFphlR+S29Ql)12e5tTab z=_q83>|!Nq2-MHr{+tN4r1wNQl*txe%t delta 2309 zcmah~O-vg{6yCL$#a{m#FPI;jwF5B=F^HrzxTS=sLZr4pYL}*$m8>OR$Wr4)W1BP_ zN~E-hDkm~Iw6`A18MTLAs#aAGQ4eXQD$+`odaH6n$+=bE8w0@$R#)<;nfKoJ{^rg2 z$s6BKhhK$45(nSI#c!=A(fx3e-`vkl4J(|+Y2K2*C=rRRg;JmxB*9{cgoIAJqZ_ z+@`R}&m3ElR0nEMkDsOX&4gUUbO&Ns51l2-Mox>ksKXtoQC((K5ekdV@U0Ye+{I6K zU2Qs682y(|6~?q4R}=-JyxZOo+-V8D7o%wLR$Cr>6LV=U&1tG8yF6q&c<9x`Z6y}Q zwLTa1QU_}Cw2+fCDNJbnF7ikR@_?J1m}i+^(FR=}Mmu;Ia(j>=e9A?>+<~0Fh&=2f z=Q@y;bL8t2TE;~l1M)?4oYfO;4bSJbOD?LqF``bszynr!Ct%cScEe~`cCD_8^r?`c zFNN{L$*xDdI0zF5YCW?6I^p~D z2HHD*V%}+zy0v4fUPr8$RkL9d=4lK$=RBz>MM#0CAu9M|;H}sHMScPmc>qUPx2h&l zU|AOc6!=K!(3YR{|q+A-d{25LXDkBy=pO>;e5PSUb`d zq+^sD_qR>e<3zWKxor@$T(R%fsDTEsOI%+YtkLpN9z-VL+CQdNakSBB9_LBK59PsZumu_~rbZh>Z*ei?Q= zWR^aP3}moYGLQCW55e(nSJp`h#5(;wvJ@gWVYw?#SzE@2N%OIf7iK{K%}bm#k7>&F z1~H8-memDLyOpQT^Llw5~I{EfTKt+kqARmuCn+o6BOkf+8U zi`Yg|pg+ck$6HI>YY69NdKIMz52@7i-X7~z7-b4VAAlngv(Y3q5`m2>5@r%bt%86i zGKHpoZtpxRfh=F^9B-o)yBr_kN StreamingResponse: - prepared = service.prepare_download(path=path) - return StreamingResponse( + prepared = service.prepare_download(paths=path) + response = StreamingResponse( prepared["content"], headers=prepared["headers"], media_type=prepared["content_type"], ) + if prepared.get("cleanup"): + response.background = BackgroundTask(prepared["cleanup"]) + return response @router.get("/video") diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 1e16a310fbe92d3f898dd220827315c8327d19eb..9f2333566238579caf60208529d2339fc5570e53 100644 GIT binary patch delta 10055 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 diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 8b560a8..ef44c03 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -1,5 +1,9 @@ from __future__ import annotations +import os +from io import BytesIO +import zipfile +from datetime import datetime, timezone from pathlib import Path from backend.app.api.errors import AppError @@ -353,31 +357,18 @@ class FileOpsService: height=metadata["height"], ) - def prepare_download(self, path: str) -> dict: - resolved_target = self._path_guard.resolve_existing_path(path) - - if resolved_target.absolute.is_dir(): + def prepare_download(self, paths: list[str]) -> dict: + if not paths: raise AppError( - code="type_conflict", - message="Source must be a file", - status_code=409, - details={"path": resolved_target.relative}, - ) - if not resolved_target.absolute.is_file(): - raise AppError( - code="type_conflict", - message="Unsupported path type for download", - status_code=409, - details={"path": resolved_target.relative}, + code="invalid_request", + message="At least one path is required", + status_code=400, ) - return { - "content": self._filesystem.stream_file(resolved_target.absolute), - "headers": { - "Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"', - }, - "content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream", - } + resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths] + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): + return self._prepare_single_file_download(resolved_targets[0]) + return self._prepare_zip_download(resolved_targets) def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) @@ -660,9 +651,104 @@ class FileOpsService: @staticmethod def _now_iso() -> str: - from datetime import datetime, timezone - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + def _prepare_single_file_download(self, resolved_target) -> dict: + _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative) + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must not be a symlink", + status_code=409, + details={"path": resolved_target.relative}, + ) + return { + "content": self._filesystem.stream_file(resolved_target.absolute), + "headers": { + "Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"', + }, + "content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream", + } + + def _prepare_zip_download(self, resolved_targets: list) -> dict: + archive_names: set[str] = set() + for resolved_target in resolved_targets: + self._validate_download_target(resolved_target) + archive_name = resolved_target.absolute.name + if archive_name in archive_names: + raise AppError( + code="invalid_request", + message="Selected items must have distinct top-level names", + status_code=400, + ) + archive_names.add(archive_name) + + if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir(): + download_name = f"{resolved_targets[0].absolute.name}.zip" + else: + download_name = f"kodidownload-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip" + + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for resolved_target in resolved_targets: + self._write_download_target_to_zip(archive, resolved_target) + payload = buffer.getvalue() + + async def _stream_zip(): + yield payload + + return { + "content": _stream_zip(), + "headers": { + "Content-Disposition": f'attachment; filename="{download_name}"', + }, + "content_type": "application/zip", + } + + def _validate_download_target(self, resolved_target) -> None: + _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative) + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must not be a symlink", + status_code=409, + details={"path": resolved_target.relative}, + ) + if resolved_target.absolute.is_file(): + return + if resolved_target.absolute.is_dir(): + for root, dirnames, filenames in os.walk(resolved_target.absolute, followlinks=False): + root_path = Path(root) + for name in [*dirnames, *filenames]: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_target.relative}, + ) + return + raise AppError( + code="type_conflict", + message="Unsupported path type for download", + status_code=409, + details={"path": resolved_target.relative}, + ) + + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: + root_name = resolved_target.absolute.name + if resolved_target.absolute.is_file(): + archive.write(resolved_target.absolute, arcname=root_name) + return + + archive.writestr(f"{root_name}/", b"") + for child in sorted(resolved_target.absolute.rglob("*")): + arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}" + if child.is_dir(): + archive.writestr(f"{arcname}/", b"") + else: + archive.write(child, arcname=arcname) @staticmethod def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: def invalid_range() -> AppError: diff --git a/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc index 815dd447b1678582ffa85e71e20f4d99768e038f..82594abf09709632848762c81e4f32a548f598eb 100644 GIT binary patch literal 12924 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 R{=w-Vo%!0%IGU*>{4dBUP167X delta 1599 zcmZvcPiz}S6vlVfyX&~@az+!^pp1c;* z_{LB&Dpez+QZ>l$z8SW0Ba8P2ba^pPV@%oD8h%Nw^v}m3F6^|*zf#>!bTBy_=q&-V)tuRP;It&w_`>+;EZ){r zc!Tw`DBflnywBWLgU8;KsuD0$0@-8`F#Ak7D_u}P;S><0no&~`r13sgP>_=*Naqw3 z6yl@{3UdkxikM;OTZtGkKaCq&TK+M$`A~i+vpn8cCs+i3Qp?vAz6f7&YO6NH3Dwn$ zjV1t<-4D1PK!g(nNkY_@YS?SG)3BZT6{~&8252^H7c@R(D8Ay_tz}44aGoT~#ia~| zLg6svIQXh-dnecY+>7nYPODwRTUv^xH$TyeY+r_t8>&bH<07`+vTFb%#6e=Gvt}?T z^0v#k6d;E|y~IXvhO4nz*WYDK$KOM9EQHU8GsS6|snuIe+wuHB2U;%ePoeO3Z!L-i z9mn6vYcq%#POu_=82+4X$K^( z&h>xH4E(?tmVc1&TCsrT_z3RJrI?H#rJrE|JU%qS7}h3IbdJYOMddE0DpZ@1d4pdC zX+<)ECw!&RuDk0+oGy5h~y~A92Z}?;I^GRvZydrc#lhl1*-Fn zc5*@3eu*b{O-=n{ooG6jYOQ9&f;PA3RCD3Xf86lyuCr_olcmQdQ|0N% zSqkepVXM3?@juhvFTci);iK}xeuD=aa<4#i>`w5;cK-C06K%&fl-`LLBTMRr=eQVA zyt;e;*aWppB%-Lth)$BcN@9`J@#o#SYr}+;NE{}J5|O0%?M`vIE_LOwy2QE}SuJ#R zP2JIrhtw+T4r=N#){QIban|iq)CKm4>nYYVBMV=*tkqi6u`FYoAGWOJj@JR(vVe{~ z9HfZAjC;j}r$lhaX?mXRdVY|0uGKsb^sd5Q8dRvu*P4!Ng9l^y-P8~pz~82}GY!+z z6B+s<`0?|#`bGXVDA(53%9I#FxHLV^!ni&?d4|3mzP`j0yinsV;*Ty1ZxQ-r9en-Z zYP->C*@xgQ9z(CU`wo|_0AuW1Y4m$3_CN|hkox%#l|6ImgX|Q$_43#G(pULXSEAO| NRsLS=8B(aWe*=|8ax?${ 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 1317c6b214a7725094657ad60fc56bb3e0e9ef3a..91b2992410f0d6e2e26aff827a6500f760b573a1 100644 GIT binary patch delta 230 zcmaDmmFekJCf?7yyj%=Gkms>A(|03pPdKCIqdCR%B-UaSzK6_6z{ z*)K+t1EyhfPE0N%qs`=7u`-kUisTeEt*dkr^O7_2i}YX?0L@Ix%t`f2%uQ9Oso5+L zH(QF4XY-nh6-<-MTX-jXGzm_AS<63pZ#Dns3Ds7NlevJLt#$m9f7J*8apmMAbpn&s QtNABKHuG;@Q0vPJ0D_NJRsaA1 delta 237 zcmaDlmFewNCf?7yyj%=G5ahZwGh`!gPdKB-ne}Qm!g#>C&vhiE2iX^=jG%lrX(iiqy}f^rRSt- zT34A*W{)vs1@ksL#N;wEDo>kXVudlvYU0OU}qI(u3($u-(iVH(QF4ck|MU z6-;an2h^@`cus!TD6u)aI)YI>=|W1@6^`s196bHJoxB&s5-ucVT;a%s^ZhRbhF{@` WnEaxqX>wHq|Kx2A{F^&#V|W3>{Z{Y* diff --git a/webui/backend/tests/golden/test_api_download_golden.py b/webui/backend/tests/golden/test_api_download_golden.py index 3f487e1..6e749ea 100644 --- a/webui/backend/tests/golden/test_api_download_golden.py +++ b/webui/backend/tests/golden/test_api_download_golden.py @@ -4,6 +4,8 @@ import asyncio import sys import tempfile import unittest +import zipfile +from io import BytesIO from pathlib import Path import httpx @@ -55,6 +57,75 @@ class DownloadApiGoldenTest(unittest.TestCase): def test_download_directory_type_conflict(self) -> None: (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/docs") + + self.assertEqual(response.status_code, 200) + self.assertIn('attachment; filename="docs.zip"', response.headers.get("content-disposition", "")) + with zipfile.ZipFile(BytesIO(response.content)) as archive: + self.assertIn("docs/", archive.namelist()) + self.assertIn("docs/a.txt", archive.namelist()) + self.assertEqual(archive.read("docs/a.txt"), b"a") + + def test_download_multi_file_selection_as_zip(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") + + self.assertEqual(response.status_code, 200) + self.assertRegex( + response.headers.get("content-disposition", ""), + r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', + ) + with zipfile.ZipFile(BytesIO(response.content)) as archive: + self.assertIn("a.txt", archive.namelist()) + self.assertIn("b.txt", archive.namelist()) + self.assertEqual(archive.read("a.txt"), b"A") + self.assertEqual(archive.read("b.txt"), b"B") + + def test_download_multi_directory_selection_as_zip(self) -> None: + (self.root / "dir1" / "sub").mkdir(parents=True) + (self.root / "dir2").mkdir() + (self.root / "dir1" / "sub" / "a.txt").write_text("A", encoding="utf-8") + (self.root / "dir2" / "b.txt").write_text("B", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/dir1&path=storage1/dir2") + + self.assertEqual(response.status_code, 200) + self.assertRegex( + response.headers.get("content-disposition", ""), + r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', + ) + with zipfile.ZipFile(BytesIO(response.content)) as archive: + self.assertIn("dir1/", archive.namelist()) + self.assertIn("dir1/sub/", archive.namelist()) + self.assertIn("dir1/sub/a.txt", archive.namelist()) + self.assertIn("dir2/b.txt", archive.namelist()) + + def test_download_mixed_file_and_directory_selection_as_zip(self) -> None: + (self.root / "readme.txt").write_text("R", encoding="utf-8") + (self.root / "photos" / "nested").mkdir(parents=True) + (self.root / "photos" / "nested" / "img.txt").write_text("P", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/readme.txt&path=storage1/photos") + + self.assertEqual(response.status_code, 200) + self.assertRegex( + response.headers.get("content-disposition", ""), + r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', + ) + with zipfile.ZipFile(BytesIO(response.content)) as archive: + self.assertIn("readme.txt", archive.namelist()) + self.assertIn("photos/", archive.namelist()) + self.assertIn("photos/nested/img.txt", archive.namelist()) + + def test_download_directory_with_symlink_rejected(self) -> None: + target = self.root / "real.txt" + target.write_text("x", encoding="utf-8") + (self.root / "docs").mkdir() + (self.root / "docs" / "link.txt").symlink_to(target) response = self._get("/api/files/download?path=storage1/docs") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index f39a314..954cbd4 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -215,7 +215,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function openContextMenu(pane, entry, event)', app_js) self.assertIn('function closeContextMenu()', app_js) self.assertIn('function isOpenableSelection(item)', app_js) - self.assertIn('async function downloadFileRequest(path)', app_js) + self.assertIn('async function downloadFileRequest(paths)', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) @@ -239,9 +239,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js) self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js) self.assertIn('elements.editButton.disabled = !editableSingle;', app_js) - self.assertIn('const downloadableSingle = items.length === 1 && items[0].kind === "file";', app_js) - self.assertIn('elements.downloadButton.classList.toggle("hidden", !downloadableSingle);', app_js) - self.assertIn('elements.downloadButton.disabled = !downloadableSingle;', app_js) + self.assertIn('const downloadableSelection = items.length > 0;', app_js) + 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.copyButton.classList.remove("hidden");', app_js) self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) @@ -250,8 +250,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js) self.assertIn('openCurrentDirectory();', app_js) self.assertIn('openEditor();', app_js) - self.assertIn('downloadFileRequest(selected.path);', app_js) - self.assertIn('anchor.download = selected.name;', app_js) + self.assertIn('downloadFileRequest(selectedItems.map((item) => item.path));', app_js) + self.assertIn('anchor.download = fileName || selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 8f7fac8..7495988 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -384,15 +384,15 @@ function openContextMenu(pane, entry, event) { const isMulti = items.length > 1; const openableSingle = items.length === 1 && isOpenableSelection(items[0]); const editableSingle = items.length === 1 && isEditableSelection(items[0]); - const downloadableSingle = items.length === 1 && items[0].kind === "file"; + const downloadableSelection = items.length > 0; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file"); elements.editButton.disabled = !editableSingle; - elements.downloadButton.classList.toggle("hidden", !downloadableSingle); - elements.downloadButton.disabled = !downloadableSingle; + elements.downloadButton.classList.remove("hidden"); + elements.downloadButton.disabled = !downloadableSelection; elements.renameButton.classList.toggle("hidden", isMulti); elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = items.length === 0; @@ -496,21 +496,21 @@ function startContextMenuEdit() { async function startDownloadSelected() { const selectedItems = activePaneState().selectedItems; - if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + if (selectedItems.length === 0) { return; } - const selected = selectedItems[0]; try { - const blob = await downloadFileRequest(selected.path); + const selected = selectedItems[0]; + const { blob, fileName } = await downloadFileRequest(selectedItems.map((item) => item.path)); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; - anchor.download = selected.name; + anchor.download = fileName || selected.name; document.body.append(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); - setStatus(`Download started: ${selected.name}`); + setStatus(`Download started: ${anchor.download}`); } catch (err) { setActionError("Download", err); } @@ -782,13 +782,22 @@ function createApiError(response, data) { return err; } -async function downloadFileRequest(path) { - const response = await fetch(`/api/files/download?${new URLSearchParams({ path }).toString()}`); +async function downloadFileRequest(paths) { + const params = new URLSearchParams(); + for (const path of paths) { + params.append("path", path); + } + const response = await fetch(`/api/files/download?${params.toString()}`); if (!response.ok) { const data = await response.json().catch(() => ({})); throw createApiError(response, data); } - return response.blob(); + const disposition = response.headers.get("content-disposition") || ""; + const match = disposition.match(/filename=\"([^\"]+)\"/); + return { + blob: await response.blob(), + fileName: match ? match[1] : null, + }; } async function uploadFileRequest(targetPath, file, overwrite = false) {