From 6e7b3cffaeea73c689414d74d8622aea36ecfdb3 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 16:27:21 +0100 Subject: [PATCH] Multiple folder move added --- .../__pycache__/tasks_runner.cpython-313.pyc | Bin 5747 -> 7481 bytes .../__pycache__/routes_move.cpython-313.pyc | Bin 1043 -> 1258 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 6459 -> 6631 bytes webui/backend/app/api/routes_move.py | 5 + webui/backend/app/api/schemas.py | 6 +- .../move_task_service.cpython-313.pyc | Bin 5566 -> 9003 bytes .../backend/app/services/move_task_service.py | 175 +++++++++++---- webui/backend/app/tasks_runner.py | 54 +++++ webui/backend/data/tasks.db | Bin 36864 -> 36864 bytes .../test_api_move_golden.cpython-313.pyc | Bin 19622 -> 29822 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 6459 -> 6743 bytes .../tests/golden/test_api_move_golden.py | 199 ++++++++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 3 + webui/html/app.js | 29 ++- 14 files changed, 428 insertions(+), 43 deletions(-) diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index b700bb7631712a69411bd9f87a946935c0bdeee4..d6e7692324e87d0c33c30db464ecc01053a88f60 100644 GIT binary patch delta 2267 zcma)7TW=dx5I*bOwRe5DvyPoOmvtIDahs%VZjdy+Unsq$>1x}kZW_{LUEC&4ySA%< zKrRJ|^aUD@KzTwzs8TD01j+*~NT{fNKs?2%gjZID_yGvHO%VuQICC7wAp(i>@a=bI zXXebDota%}yWj6zaX74qEYHm^6JG`AoZm-2Cc6*OIixcR(oG7Zvx-A8)uKzB&M8iX zrHuc8k+7TLTU-i91!l#q@RV5;k7A}wAnF#%#8vgMOA)BRO4LQlY((8knO(8zj*IrF zcQMWcd4|Q?d5MYRU)Y`YZW4!L>SL3v%Hlb`bI3KSXOg2+)7P}o3uEc=SM@p)b(GT` zTMq|5~5>#%5II`!Crb6y|5;^MGb^f4Jc;pJNAZpWXz>fgf2^2(n zoAJb?rXK_0QT(g3+IAf134lTDay8Sa^^>q4z`I<%%-O|^>mw6$7u&sZwg==2fpYHW zKrjLTr$^1D8`%$n0{{mB)|8^c#+aNmrip~Eji;ye*Ghq{3kjU^Yabnd=Cv~?@jdSg z%=@_L{UJNf5{2@D(ja?g<&+5uJjRq?sHUD(PRh&Fx+)$M5K>9m@no-<00&7!Bsmp< z{l;|)6gDh%gi#okRXK(2<7A{TNj_#N>r@V{LWgf4lp#*9TvRQkqmlw)9QaI%4Tx|a zxMJ=fMrf>-1cputc+wtm4&PKPeI^+V)=<%cJ(4YpRI?(qg9pCMRJPws>`A{5M@sLg z*VW`yA!+@Of%T&kcT1wkB&wNH>yBiSWwdu`yb#NtdG?PMEK zoQc{`=nz?{r((dMu?pPfL~^1aR!R)YS3e6tOIFb9v#{Avvuo{Ksb2j&(Tmy(?4*{g z)Nw)7l9?$@A4_Yr&Gb?5b{e1`;Ge}z3p{FHt7xQ?LG;RM3qf}kT;tPIS0=TzHZe*E z9GP>;32mmp!LBke3Sv^5NmCWtttn`67=h(gniw$a3bimXvUK*{A^TK2;Y%!07&kKW^E?S^2_3xS*v$P3||5H_Ts;SU&rO-4<)C?YZP6!D_9 z=tRM~S=W6^1d8s)kHRq+SoYZx%cS-76mSR6w#2q=Qyq5IJX zmml{J%^w6=uCDFQ@N!-6(wUK4qT!bEZaL?c^X_dq_co($lhN2=$XkriW)iYz`$`t^ z9`92hs%tCuBhgXLuO%n6(EOT6enkrsTc06LwEpoth@{{OvXS5`p4)QnHtIS0q$#{G z{%NZrM~uczhTL2_tmr|W_NPencz=_S$NyK+Pwf9p)sRd7E>OL zJrLp_g?Pg8*Hv4xgG8nDaVdk!)hUyKy##O=;AH}&eCmP!51R(XQ*m5xm$#?!G>T5ppHjEt=F?Zj_LM||7Be-I!Huo-~- zwxk~BHn#Xwe8zX!_WTm#=bgWIw((Yc+xK?uRZyw|p#N}TU}}0IGpX&;ui<^wO|By> KV||7IsnXw4iP`l4 delta 764 zcma)&%WD%s9LIO|mEE1}1CrXqdP~u9oKafaW?P2 zseQ_9c<%?Eh}#S0ioyv~xXK9E7-edYvaEBRrI{v`@tkBKAV$Gux-?AYF+-}9NHV3e zc9TMvrKDktBugrX*__^VBENZ$ghumoDy5po#9$u7SG$*#;FrBdMxgG@x+B8g(s%)* zCD?YR+!D$n+PZMM=tGJ*EiJjT$Sd%Q4wJa~g%-8$7!$GWmdblkA3%(O?=R6oltYLz zJn*xoY!Smot_xoK`$-)>_!H$(j2}mwKuiimlvC8F5ov*zU2oh@t}WG86Mh!WGcX?P zqvuebM^s@U=#wk*8N65ESx_eP&Fx@QB@tZB6xWZUKPM3FKJ*eAmk~HUGTYBTiN-i$ z0sU#YM+SGXi}+ahnMsowu(K8N7$&nHVXWuydKH86 z*QAnPCsh#xQ;2EADS;^6UhqF^21I&Gzusu9c9dKHmghw4KQlWfK^#_Th?Dyfhu~>A rOkTpf@FEoQ)8>z5(rtLKl7B=N;9EXN60iyf{R=AbzAH$c5oNyt>`9ga diff --git a/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_move.cpython-313.pyc index 9f04b648648b16a1655c4c7d5591fb3d83616a14..64ca8e05cb9f2d0862b361879a00e9e51d51429f 100644 GIT binary patch delta 478 zcmbQt@rsl0GcPX}0}$Nl*qABJypgYm(QXTntIVLlFqI*cAqXUg29i`57(y9?n9+oR zT#)+7`xy1=fzs?)r8%&Qf?a?r6U=1Eq{3j%oW`ih?5D|ei@i9%v?w{X_?AFQYH>+s zUSdgReqMZ1VsR=^k_{qxiyJEE2$IxfE8+*5c}pz0C^fMp6{xl(IU_zdzbrMrB(XTV zNB|@$0F^`t3j&3UgeI#p`MXO1nLI#T91SF1GBhxJ;9}t5Z?A8xzsRn1gJ0}AzwSkT z-5Vm()BPv<-;h+Ai>G?fn#zzlaeGG(1<2SF-HkTKcK@z zfxIG+P?6Z=157(*KsK#p_zWZ&io}7$Ee@O9{FKt1RJ$VO$y1qqj5rx%7;o@Od|(DK j9`MS1UgWWbc?MxzqBYh^%i$ZYH>+sUSdgRex4@tErH~s)Wnk1_}u)m)cBIb;_M=R zpm{|ClaDd^3yT7oJV0Em4kTVOG%$SNnjFKdB+dflHmRyJ`f0Ki2?5zfY#>5-@^t2% zG9W!G89oC^h9VIlaf`zyH$SB`C)KVAxP+%*M5$}k})>d>vaRM3Y|OA+*i)e=UyHM2&Dpcz=uMS7vX zp+lF>{S%T9o3{=ToeMhkG4IsE{`P(MnR(`!nfsB~QT1I_6_LJqAq(!Em!?*?ImuuhA?t z?||4Vzy4BPET)Xt{SP+UMChW&D7LroyMKMM2!Zk`;Rp7cP1PTgK1KMhv1NM zf_{h!v}zG@g2x3Dni07~A&yu=_z*paJltx1N&!p6B|K?yQV{bdVO-bA6)fvxVNAym zS6FN!H+ee=swgHj7iQ;SPp)=xeqq$^nhQUD_AL5w^Q0oGFj)nhn0 zO;nt*WpbUUzG^v07DTiFG0ZM5-e%7m+89OK2i%GCmg7kz>?iZHh zss=GZMCasIF%`yYph}0yuf)U{CroA(HO{Q1kkq}+->dt++$$l=*gAQ tmJM$rkH OUl@SY7p}=$B#i+|xLWK0 diff --git a/webui/backend/app/api/routes_move.py b/webui/backend/app/api/routes_move.py index ea42f85..31213eb 100644 --- a/webui/backend/app/api/routes_move.py +++ b/webui/backend/app/api/routes_move.py @@ -14,4 +14,9 @@ async def move_file( request: MoveRequest, service: MoveTaskService = Depends(get_move_task_service), ) -> TaskCreateResponse: + if request.sources is not None: + return service.create_batch_move_task( + sources=request.sources, + destination_base=request.destination_base, + ) return service.create_move_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 3a1dec7..58284c2 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -125,8 +125,10 @@ class TaskCreateResponse(BaseModel): class MoveRequest(BaseModel): - source: str - destination: str + source: str | None = None + destination: str | None = None + sources: list[str] | None = None + destination_base: str | None = None class BookmarkCreateRequest(BaseModel): diff --git a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc index e06c762b8afa8a2edaa0579c41557fc2e57a1dda..b1b6ea5a64f2f0b3b28f1d7edaea036e1902f4c6 100644 GIT binary patch literal 9003 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 H&gA?bv^en( delta 2303 zcmb7FU2G#)6~1G8Y>&skGvo1i{4-9R&937#&iQa3lFqHNG%U2NC@6gMtPXgC@b-Rcq+nSBk>6Lj^mIA zB*4;~IX>rn-}%nb+?k)h@SAesd`8;05H0{R}WJTb4N4oezmW`CJIm!D=*;GkH9*BlnS6nrJz?0sva5K z^Fg1=s9x8j)sX6QJ^y+@WmTH^RhJeM2US00-VBt&zxv=?JH?Gai+$`E+X7z_Xe|-%rY+GmL~NA*lpc^QZdG-Das%q?f30wFu(e zivV=kb4^X$l*RMpC=TriW2Cj`v{%)Gj`>_7sK(ULH_k~xTcIhx8n1g-^JJR;++9u7 zyqz$fcoMx8;?a9ao~wsdj>ZDl$|IHThLt44o<)YO(Jzurg|B%FVC}h4?FfzU0%1{; zHO~ZCtBmr5%d>hJuO~xYL5~<76_NpTKQ#S+N~Nk~sKo!SRY?Y?>ybLEiZ%Mb3ofP5 zAF%Q#KJ>_!Wt>1$H?&r}hiI{qaAK{_1FhX^HlY4iU+)>HCSFLlKEw}8$pdqEQipBg zaI2#kt!@W(#UJcAzOC*9z2tS6?zRryxa042`*2f-7YM@BYT|6b(0cb9ttKqt*f8Pm z8Jf}WVP>*@IE_t-9{T?-VvVk$wHq7n7<$iPdfJxWfZeW9%D@;QPbQ(>>$V^04gKvF z5%y+d8y{mdle!pkg4zbo^$i_;Go_Rr|F#D8j^Xgwz<2P7ZLAg}3BaN8*jf)3327Qp zxis2OF>G*D4z_{eBZpl39Ct#@@N zp?B~)`+9?{s`2esTQ5Z+hpW$RBX&ASvoTyA%1d7s9pNl`e(&FsCc`tNna`E*96FAq zC=u2C^UPH*0Pg`?&Lc;9g9@Ws`qhaEOR1Pj#Zu-?W&X3ZL*;7+!K|ITY~|+6+?sIl3v-te}*RADsb9w!+xPHXRc5>2|ZrEAH9=m8t%f}4OM+bn8 zL}8X^>_RTs(lr7}D+gm$Yiz+BTR4yw(Z}-K&Wo5K#bJg7 zHwc5cV8y1)*wo`it9;We-#obWx^-*AytQG)HqF@Pk(jsBQ+9UAE)?wxWixvo9R-49 z@kNs_T71RiE4Ey)Q@)b+IZpzmW<(efgnex)1_?3sO9pkN-V#X9JRyJd0 zdq>RL{J6!JO}=bP)0R{v!$XfDgzcU7mtCT37y12eEjjmW8=xxXRqyF`uq(uwsH_$foc5U zppPX+$-5qw z<_0qGbI&|9oqpyWdG4BB=_D;eS9;>AwSF-Qs@%;MGZH diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index 6ccec9d..1418173 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -5,7 +5,7 @@ from pathlib import Path from backend.app.api.errors import AppError from backend.app.api.schemas import TaskCreateResponse from backend.app.db.task_repository import TaskRepository -from backend.app.security.path_guard import PathGuard +from backend.app.security.path_guard import PathGuard, ResolvedPath from backend.app.tasks_runner import TaskRunner @@ -15,7 +15,108 @@ class MoveTaskService: self._repository = repository self._runner = runner - def create_move_task(self, source: str, destination: str) -> TaskCreateResponse: + def create_move_task(self, source: str | None, destination: str | None) -> TaskCreateResponse: + if not source or not destination: + raise AppError( + code="invalid_request", + message="Source and destination are required", + status_code=400, + ) + + item = self._build_move_item(source=source, destination=destination) + + task = self._repository.create_task( + operation="move", + source=item["source_relative"], + destination=item["destination_relative"], + ) + + if item["kind"] == "directory": + self._runner.enqueue_move_directory( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + ) + else: + self._runner.enqueue_move_file( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + total_bytes=item["total_bytes"], + same_root=item["same_root"], + ) + + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def create_batch_move_task(self, sources: list[str] | None, destination_base: str | None) -> TaskCreateResponse: + if not sources or len(sources) < 2: + raise AppError( + code="invalid_request", + message="Batch move requires at least 2 sources", + status_code=400, + ) + if not destination_base: + raise AppError( + code="invalid_request", + message="Destination base is required", + status_code=400, + ) + + resolved_destination_base = self._path_guard.resolve_directory_path(destination_base) + items: list[dict] = [] + resolved_sources = [self._path_guard.resolve_existing_path(source) for source in sources] + source_aliases = {resolved_source.alias for resolved_source in resolved_sources} + if len(source_aliases) != 1: + raise AppError( + code="invalid_request", + message="Batch move requires all selected items to be in the same root", + status_code=400, + ) + + root_alias = next(iter(source_aliases)) + if root_alias != resolved_destination_base.alias: + raise AppError( + code="invalid_request", + message="Cross-root batch directory move is not supported in v1", + status_code=400, + details={"destination_base": destination_base}, + ) + + for source, source_resolved in zip(sources, resolved_sources): + destination = self._join_destination_base(destination_base, source_resolved.absolute.name) + item = self._build_move_item( + source=source, + destination=destination, + resolved_destination=resolved_destination_base, + destination_base=destination_base, + ) + items.append(item) + + task = self._repository.create_task( + operation="move", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + ) + self._runner.enqueue_move_batch( + task_id=task["id"], + items=[ + { + "source": item["source_absolute"], + "destination": item["destination_absolute"], + "kind": item["kind"], + } + for item in items + ], + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def _build_move_item( + self, + source: str, + destination: str, + resolved_destination: ResolvedPath | None = None, + destination_base: str | None = None, + ) -> dict: resolved_source = self._path_guard.resolve_existing_path(source) _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) @@ -26,6 +127,7 @@ class MoveTaskService: status_code=409, details={"path": source}, ) + source_is_file = resolved_source.absolute.is_file() source_is_directory = resolved_source.absolute.is_dir() if not source_is_file and not source_is_directory: @@ -36,8 +138,18 @@ class MoveTaskService: details={"path": source}, ) - resolved_destination = self._path_guard.resolve_path(destination) - destination_parent = resolved_destination.absolute.parent + resolved_destination = resolved_destination or self._path_guard.resolve_path(destination) + destination_absolute = ( + resolved_destination.absolute / resolved_source.absolute.name + if destination_base is not None + else resolved_destination.absolute + ) + destination_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_absolute, + display_style=resolved_destination.display_style, + ) + destination_parent = destination_absolute.parent parent_relative = self._path_guard.entry_relative_path( resolved_destination.alias, destination_parent, @@ -45,20 +157,20 @@ class MoveTaskService: ) self._map_directory_validation(parent_relative) - if source_is_directory and resolved_destination.absolute == resolved_source.absolute: + if destination_absolute == resolved_source.absolute: raise AppError( code="invalid_request", message="Destination must differ from source", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - if resolved_destination.absolute.exists(): + if destination_absolute.exists(): raise AppError( code="already_exists", message="Target path already exists", status_code=409, - details={"path": resolved_destination.relative}, + details={"path": destination_relative}, ) same_root = resolved_source.alias == resolved_destination.alias @@ -69,44 +181,25 @@ class MoveTaskService: code="invalid_request", message="Cross-root directory move is not supported in v1", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - if self._is_nested_destination(resolved_source.absolute, resolved_destination.absolute): + if self._is_nested_destination(resolved_source.absolute, destination_absolute): raise AppError( code="invalid_request", message="Destination cannot be inside source", status_code=400, - details={"path": source, "destination": destination}, + details={"path": source, "destination": destination_relative}, ) - task = self._repository.create_task( - operation="move", - source=resolved_source.relative, - destination=resolved_destination.relative, - ) - self._runner.enqueue_move_directory( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - ) - return TaskCreateResponse(task_id=task["id"], status=task["status"]) - - total_bytes = int(resolved_source.absolute.stat().st_size) - task = self._repository.create_task( - operation="move", - source=resolved_source.relative, - destination=resolved_destination.relative, - ) - - self._runner.enqueue_move_file( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - total_bytes=total_bytes, - same_root=same_root, - ) - - return TaskCreateResponse(task_id=task["id"], status=task["status"]) + return { + "source_relative": resolved_source.relative, + "destination_relative": destination_relative, + "source_absolute": str(resolved_source.absolute), + "destination_absolute": str(destination_absolute), + "kind": "directory" if source_is_directory else "file", + "same_root": same_root, + "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, + } def _map_directory_validation(self, relative_path: str) -> None: try: @@ -121,6 +214,10 @@ class MoveTaskService: ) raise + @staticmethod + def _join_destination_base(destination_base: str, name: str) -> str: + return f"{destination_base.rstrip('/')}/{name}" if destination_base.rstrip("/") else f"/{name}" + @staticmethod def _is_nested_destination(source: Path, destination: Path) -> bool: try: diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 2111370..1da80aa 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -43,6 +43,14 @@ class TaskRunner: ) thread.start() + def enqueue_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + thread = threading.Thread( + target=self._run_move_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: self._repository.mark_running( task_id=task_id, @@ -156,3 +164,49 @@ class TaskRunner: done_items=0, total_items=1, ) + + def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + total_items = len(items) + current_item = items[0]["source"] if items else None + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=current_item, + ) + + completed_items = 0 + for index, item in enumerate(items): + source = item["source"] + destination = item["destination"] + try: + if item["kind"] == "directory": + self._filesystem.move_directory(source=source, destination=destination) + else: + self._filesystem.move_file(source=source, destination=destination) + completed_items = index + 1 + next_item = items[index + 1]["source"] if index + 1 < total_items else source + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=next_item, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=None, + total_bytes=None, + done_items=completed_items, + total_items=total_items, + ) + return + + self._repository.mark_completed( + task_id=task_id, + done_items=total_items, + total_items=total_items, + ) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f33208e6b28dc6eee758983c0abf8a6b987b1157..f045ea4439d314bbcf7bb9ee945b97eb540837c8 100644 GIT binary patch delta 620 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 delta 3016 zcmai$e{3AZ6~}k>&g=8t*}1c0pYO1J23y!0_~<(`@4b2R-rU@M@HRPhg4F!0x;iZ3bNIA%zUO#NBRO@tV=oCP{act3 zu1f~QqD0#U+qL+|_ezpcD@lV&|2^#Ai6-_IsU)@T+vIO@;azU{j5H`wqDfTN2n}dS zO-@PUK^o*LAXJ5x(t<)M+I3o!ra+GqTB$W_Em~!96yo+FVO7Odl~7@>!a_y3iU<|u zDk@a9R!C~qLe}uf8lhHkwMwWMS23aDT*ZZ|<*HVwIM>7-i`_T=lmV9uB@X$P+j?d0$( zCJtsSN<+{A+o9|~(GJqXI;wj}i@T@#X+k!zf7EOts=IF09+JM9+o=KC1OL0=qtNMG z&ZH_4LpBlbBAeM8v3atd?TT;X`b2y_atqA21NX4j+BS0&WHnF&M1W5K3Q)ov!bapUtE4j^mJ`s?W3}$w-7m@|?Nw+)ox=aSxm8LuTJjKpW(-G5)WOzlboZ(pc z+@)7xodXVVc*@kYIc;X>I3HZ8n&6GdtaWA_H6rTmg&(ArW9p7MPn%5~)h)3DQ=2K*+`&7$;VEcoZnE_@w=Bgcoj+svPq(RM`LkF<%5KFY( zmU`&-a*b#y_2qi{ z`Jht+S=e<`49B|kG~q_yg5@dp z+g%M!E5(!jcvA~~_sQRo-23}BlB;pP+<#MAl;?*C;m3jc5pWtf!!GnsD2tFUila^5 zH^i2-w^n;(rgU+x-jv@Atnu?YQI^-_*829+i9fQ&XIRf}S9T@$;%<+u6}>)-ZA-T^ zY|dD=W8>m`WVWof^X%O{NwG2g8C;45`9*Kt4U5YEY3&(y=iZ0ppG)q!y%$Imnl=|M zehY9b%NuxhU(&Bu*(TWTm%w*`Gwi*6$K>;pd*uH8%SC=?u&MqBSQQ;(WtC|fYF7Lz zFZ5l<_6^-Gdy@O?&?HH(aQw1V=9#KD`>DK!z@^5$j^VHTR7`zDz9_k`>OUq4oXe{i zIbdZoWpNmL#!&q@&wn`1w=on)T4aAQz9e6k-2Cv@$OgnI4}hV#K>x%4_RLY`0~mha zUCIoT^fg@#Wv$#n^t2e4^^GR>FW0NQp@?ygL#-_J)C=3Q6NsSmFMb9Vj{!#jJUfd) zar-^R@2MArE&p1b8?{Kp?HsF>3v(fA8rg1idjW3O=EoPh;WeNVug(vn`)`&x!Z$6u zh;#Ac^Ruq!suTSQLW&Yyb8Ey&LbOALLFwuAFkImv^iL9d$(koA_m=THR(=b+;JOwl z-CDeg^dum1cm%5S3|f5Xg-uHHR3FJ_OfO{Rbuskc#hOx~X%wy-flf)f*Z(P`ag0~W zw)9rwT~`(V4SEXZ;)iz%N+fcWt;PfMTW(-~&b?FEgmv+1@ZBW3fkTyP z6Ld4Q;)fAQi68MQEG@6o%`ghlLClJzA6QmHT+Kx?)`-(O-zt8Ggqr~mGRDb21*w;B zA@UR6c|PY=jArwOBR27Z@K;l`g)h)F&xbMKEC7djw3rS2c%$%rnuXG0bt|j7XSbN 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 f9f49eb414ec2876c9edbf594469a75c26251781..5a04505bc3db0008f7c90dc8870d6d5320d3fccf 100644 GIT binary patch delta 412 zcmdmOblrsSGcPX}0}v#4Zp>5>*vMBc$aIQnG9Qb|=JkT}7$@%)5@+O^yirJbvMQUv zv28_>iqnYTLLMm#U+_}i6xo&dGSe!#i>>L#rdU0$*ILwa1%U1 z2I%D`7HDXI%+ge_wF4WfSCCl3l%ct~S)`khNttC&Z_IS!-^f=j$P~vknU6(f^LoK~jFWc?E3$G0a|iQGzAvoG z$ra2S%wx%v$2!?iM4Xu=m~V0btAbD{YcRhl6UYz-1_g$622FvW?zB$i|*$0wGQ6lErrmZTQP#}_$IUMHc?$Upg> z#9380kUEy!#LT=RXCSjk3`7Wn1UYPS^HWN5QtgVOCSQ;Y7x80cw4A|lT|)Dsgyt6p M5bs0MWNRrI04r2Jn*aa+ diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py index 1d083e5..fbdfaff 100644 --- a/webui/backend/tests/golden/test_api_move_golden.py +++ b/webui/backend/tests/golden/test_api_move_golden.py @@ -26,6 +26,18 @@ class FailingDeleteFilesystemAdapter(FilesystemAdapter): raise OSError("forced delete failure") +class FailingBatchFilesystemAdapter(FilesystemAdapter): + def move_file(self, source: str, destination: str) -> None: + if Path(source).name == "fail-file.txt": + raise OSError("forced batch move failure") + super().move_file(source, destination) + + def move_directory(self, source: str, destination: str) -> None: + if Path(source).name == "fail-dir": + raise OSError("forced batch move failure") + super().move_directory(source, destination) + + class MoveApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -156,6 +168,193 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_move_batch_same_root_directories_success(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + (first / "a.txt").write_text("a", encoding="utf-8") + (second / "b.txt").write_text("b", encoding="utf-8") + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((target / "first-dir").is_dir()) + self.assertTrue((target / "second-dir").is_dir()) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + + def test_move_batch_same_root_mixed_files_and_directories_success(self) -> None: + source_file = self.root1 / "one.txt" + source_file.write_text("x", encoding="utf-8") + source_dir = self.root1 / "dir-a" + source_dir.mkdir() + (source_dir / "nested.txt").write_text("y", encoding="utf-8") + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/one.txt", "storage1/dir-a"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((target / "one.txt").exists()) + self.assertTrue((target / "dir-a").is_dir()) + self.assertFalse(source_file.exists()) + self.assertFalse(source_dir.exists()) + + def test_move_batch_cross_root_directories_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage2", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_mixed_root_selection_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root2 / "other-dir" + first.mkdir() + second.mkdir() + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage2/other-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_destination_exists_blocked(self) -> None: + first = self.root1 / "first-dir" + second = self.root1 / "second-dir" + first.mkdir() + second.mkdir() + target = self.root1 / "target" + target.mkdir() + (target / "second-dir").mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + + def test_move_batch_destination_inside_source_blocked(self) -> None: + first = self.root1 / "first-dir" + first.mkdir() + (first / "child").mkdir() + second = self.root1 / "second-dir" + second.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first-dir", "storage1/second-dir"], + "destination_base": "storage1/first-dir/child", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_batch_symlink_source_blocked(self) -> None: + real_dir = self.root1 / "real-dir" + real_dir.mkdir() + symlink = self.root1 / "dir-link" + symlink.symlink_to(real_dir, target_is_directory=True) + other = self.root1 / "other-dir" + other.mkdir() + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/dir-link", "storage1/other-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_move_batch_runtime_io_error_failed_task_shape(self) -> None: + first = self.root1 / "ok-dir" + first.mkdir() + second = self.root1 / "fail-dir" + second.mkdir() + target = self.root1 / "target" + target.mkdir() + + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=FailingBatchFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/ok-dir", "storage1/fail-dir"], + "destination_base": "storage1/target", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "failed") + self.assertEqual(detail["error_code"], "io_error") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertEqual(detail["failed_item"], str(second)) + self.assertTrue((target / "ok-dir").is_dir()) + self.assertTrue(second.exists()) + def test_move_source_not_found(self) -> None: response = self._request( "POST", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 92cf661..e74f713 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -84,6 +84,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('currentPath: "/Volumes"', app_js) self.assertIn('Cross-root directory move is not supported in v1', app_js) self.assertIn('Batch directory move is not supported in v1', app_js) + self.assertIn('Batch move requires all selected items to be in the same root', app_js) + self.assertIn('destination_base', app_js) + self.assertIn('sources: selectedItems.map((item) => item.path)', app_js) self.assertIn("function rootKeyFromPath(path)", app_js) self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 36aa2a4..6c77034 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -283,6 +283,10 @@ function isNestedPath(sourcePath, destinationPath) { return destination.startsWith(`${source}/`); } +function uniqueRootKeysForItems(items) { + return [...new Set(items.map((item) => rootKeyFromPath(item.path)).filter(Boolean))]; +} + function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; @@ -699,7 +703,22 @@ async function executeMoveSelection(baseDestination) { if (selectedItems.length === 0) { return; } + const allFiles = selectedItems.every((item) => item.kind === "file"); setError("actions-error", ""); + + if (!allFiles) { + const result = await apiRequest("POST", "/api/files/move", { + sources: selectedItems.map((item) => item.path), + destination_base: baseDestination, + }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + setSelectedItem(sourcePane, null); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + setStatus("Move: batch started"); + return; + } + let successes = 0; let failures = 0; let firstError = null; @@ -995,8 +1014,14 @@ function openF6Flow() { return openRenameMovePopup(); } if (selectedItems.some((item) => item.kind !== "file")) { - showBatchDirectoryMoveNotSupported(); - return true; + const roots = uniqueRootKeysForItems(selectedItems); + if (roots.length > 1) { + const message = "Batch move requires all selected items to be in the same root"; + setError("actions-error", message); + setStatus(message); + return true; + } + return openBatchMovePopup(selectedItems); } return openBatchMovePopup(selectedItems); }