From 9a7ca4e2dbb933f624041be83953cf3faa43e9bb Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 13:44:38 +0100 Subject: [PATCH] feat: feedback verbetering 01 --- project_docs/API_GOLDEN.md | 4 + .../__pycache__/tasks_runner.cpython-313.pyc | Bin 27252 -> 28823 bytes .../move_task_service.cpython-313.pyc | Bin 13622 -> 11559 bytes .../backend/app/services/move_task_service.py | 81 ++----------- webui/backend/app/tasks_runner.py | 112 ++++++++++-------- webui/backend/data/tasks.db | Bin 344064 -> 352256 bytes .../test_api_move_golden.cpython-313.pyc | Bin 34529 -> 36203 bytes .../tests/golden/test_api_move_golden.py | 25 ++++ 8 files changed, 101 insertions(+), 121 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 600c567..71e727e 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -134,6 +134,10 @@ Voor task-based file-actions `copy`, `move` en `duplicate` betekenen progressvel - `total_items`: exact aantal te verwerken bestanden in de hele task - `current_item`: taakrelatief bestandspad als beschikbaar, anders bestandsnaam +Voor `move` geldt een expliciete uitzondering: +- file-gebaseerde move-paden rapporteren file-progress +- same-root directory moves behouden directe rename-semantiek en rapporteren daarom grovere item-progress per directory-operatie + ### `POST /api/tasks/{task_id}/cancel` Success for cancellable file-action task: ```json diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index be7b7247eb17af19415da37f6d65d5e03add233c..bb13cfcf2b3a442d9bb06423f163180a91bb0486 100644 GIT binary patch delta 4881 zcmc&1YjB&z@w@MnWXY0k*?L=k$ZyMUJGNuv#EFTWggl(bMZ~F-DzdFOc5KOfQYZli zo3?<5KwX*UpjO0N@W3!+JEz%}(HwLQwR4SJx|TXP=A@l87qK=u4^-As=Q5RiPRmZCTqhw%zk!x;jwqL~Y{{Zq zPfNLODb7}P(=t9QgLQjoIpT{)DyJK11?N_PP32cvr$VcyP1KE?QBkhsoT@qz>5ZFc z^#a{pAT0mU&9sJFt6FIdv}zZ$s_7lHj!V_hEwr9vwLoj&SRHMY>xV`6)`&$_Z)NXq z%#L_+TQnpTIqrHdE$51e;nq)-D-i^b$aZZv+bN-93$SpsEHDF-M_?zi%xtDMi$!Y9 zELfYKq3+iyQYqD)R7xqVC6x=jA&Vfmgns>^s46A1+mNE&{HAyTjYrs>$xgJJNH&|W zSu84#B|vK4Hy`TWg}$n&pBt!W89DzHRQz& z)lozBY{wja4vJC)-pEbtY9zI441lKZ?9E$kqv8v*Q>G-u1W8ymHwdV%I~R^ z-l$Z=gk?5bBir--DQ=b@1#!0#Zb7+R?$B=qWrABKXn<&hmk~A10f$V-G+@;*w^AfL3nb>wE!#Xbxu-}O4k7C z2f8vmq#xz@NR`D@4f4U6Jfu|U$6V*bBTx}1DNCh-Tk4gIfs)Kxh<)EcC^&XB>_0E@ z(z+vqWYqQ$v(a9a#qev^PJrsHC4s3s^l(0W~}%Mhtx$)tYe*{ zFbx*q;qeS0Ck09ZqKBA-&E4=GNFIqbbeY$|Q@P--fXbs9fSTtKm&DHETUQG?77({r zaQ@9`VK3HN*_%6blPN0;avKFfFTgV)D)EEImT?zu6^|KS>g6eXfX^I+} z9_{%=FIaNt4T8}!ZBXJ37Gfv8IXRbLv6GNb;Nm;cThNbyi?=UHOKFK~rLRa!Xf{03 zVv<7odZdu(5=sRRcQ`aJlW^uTOvhuVEj^P6^5l@8y%`>?A_mw|516pZG`9UDprImO1idkJH|?9C@LEZOZA ztztyo6D4Gp#W%V&7w{mDu=}c=8Z7w9LRPTVsm7Zr&kf3a6dU?g*9i$Sxykk5CQf`A%K+Xa#pF{90g2h%) z{xME@S8xC^Yy#z}w?22~miaB;q zXC}0UxYi!k+T+^FsJ1d;b|!4)2{zTEA{DHm$CgtO)mA)r-<%e{`x07PR9nvu_f(k1 zVvg~7ouG4nBB*ugSgZ<}WzU_^b&Tz4Bu}ts_War~2m4Q|27H|wAJ{BNk)M26^gUF{8!b*>W=H1+1^7tn*>eH zHBqImxh4TNj~GO@LTg;4HC{Jp)t2jKt=c?qW=Dpb**k~cXmO|uuVkC&)$pCy2wATA zG{m$*ZvMOuv2?-enAam_5YjWQXCT%ZDL;IOkXP8_BPYmbtZ>v$>RH=pb@qgw^pQ^m I{^6Vd4{K4unP5`Gp07O;1zqcM%q z{v@{1NRCa$PBX=tPGQ<+VxMho?WD=HnW^neh1d~G(?qAvG#P8M1Z(SbI_W(ZTBI|5 zZ}xlNIq#hB+@E*uzIcn?`#rTU*lg)UT>0Pm@xZ@E$L$a5(sYD;opcaR8Q}_}T*ub1 zwM^$DgZe?r^{k5Nk#hs9W(LWOtOgor@t#^{l6IPSlO~y&)iASU>4K$8W?}1?MKUX^ zV^+y*Le(ajUF6y&bBGLwWKMX4df+NG9%Z%6DP0*NEJLzP5tb=gmS9*WHMR*FqhA@mUB4EzlIXrqW-q}0A<2_@e{BURAKq8)m#tNS^i9ugR z7(@69TwosfX@yE-@TTtz)C*0yV~|?i47giVBm|IqxR1_`YbH0udDh0`51Z};fn~T z5#(&gp`*TWNm#2drpL8E)PGw~UxFtZrpy;bm1L#>IOg_h&5cea^A(YqR0IS9+=VKW zFz`qjy#Z$)c_HIz;h0k9^ofDw*qp9EJ_Z}ZRZzUe4adU$mS-^h62fIz2v^hFTFI8} zl)ewirWid5SDJ$KJ-E|!*@ZGmy?}tY7OR|spGAW7j`ntBEu|lUZQC}jIcA`si{{X? z;E%S_GcXj*qvLQQsw)4W+QsM$wM=3$6yizvw9n=`|9I62leHkHPm%RF`FYd{*twis>2MU6&$@GY)k!G+P#)DGR?Ix>nZwYu9W``%FvwbjM@YTDqo(c#4p2WmuVZ zsDe316oKXug=fR~4ljL8``(UEDR6LSwz*97pfN^vrh^!wY{MV!^uhP*(pZ2*D+sZW z0Ey{CgwNs#t&6+#M;y2OO6BLj4^2D5@^Zb6CT;@%6T%M=-oYI-P6#>|fQmi27TK7+ zIJ-mI0{bLgd)phFD8KHle5U1w)j4bR&shDl)|we>&Be$i@9Xt58@6Aojb695&g+P8 z%}qx>RPWtIufmDFRrITHdGAlGS5REvJvy4;zkw&)>*y)%NA0Dw@DBRTJzXO`@kBy= z9)A@!-z`AZFShU%__!lNe+wTrxM9z}HS~Gy#J*JuozyOLeuFCa2t>PL^cULst_o_J z$K<~gxZb^Bbw4lQ0;(3thJ?ExWL}Pv4hae6;@qY@1 z@fOiViTKY8#m*2;Fi1hr3s}*(^QaoI|7Ym)LY*76Lwx~C@50&shNinHFocj0pehe> zJ;!rkanqI~^jvH#;Z`gsy*BXfUq|iGw!a)&2dtt;U)sNoTH&#QoVBt6mrD#{3F~4< z+E4%w0WMh(nL{KM3Xu$%USIMaI_0q|D>+1pSaKP*HviMmUD>GQxR;2?W{6^1~fL^8mt^5aell zADR4P5Nm#9$d~^YbYvsQBh-ux!6tsm`=AZoGL#rL-rE6Z6FcDD;aX@Le)FiwFrwVd zaHR}LQbytkq)d{g5no=)ENMFNcvBWhtt8!c&nBs#6cpVk2;9i^&pSeff}3tNWe`uH z;S8lr$kIusKV?B?MKK#PJ8EHk$|gc#{jdJJ#np0^I5>QL!fQ0CQa;=f7SeB^D0YN2HoHSIF17cc+#hGE9JG?&t0oyK)(J=Rm1 zK5a6bY04bjT-0Erj&pMjn}2kDGRn}+?ckeL@cIk*ru&bX?x0n9br%pq5ug2 zfXw|IXlK~~?3~d_mb;8fuxg$t6pHGEnkyC*y3`+>P-jP%Z!Il;N%bq>IKU7A%pr(% z$cjXy3xGNO9Hd#=ke+dDKz3x%7#{-chaB7UlZc#%-|?x%a7>}Ef8yOkzXDQKv z1u9EUDxX%W;;uA5F)8OIBW`8xZ47!D?Cyq-u;mzoqovFCCJ2v_8;LuWk*M(_J9E^*UK*@YKtF7mNEus_nWda4}htpp>hZ5K~en%k~e%gqCo zc=yeoSIY6>N^8f>&OPPUy;}~G!@1$6t{ZHVngN*FH-OC~Y=y}kx3A+Z2pT8h!>WJm z;pP~m7K)PwzwU_*d-d0!^9(oWe{gpVhlMGgd?&d1cAhmAIpgt>8<6A7Zzdbw5V@Bya+Ln;f40e<&?VuH zhXXmZOK>uz`|^ikWZCc1S)O!rk#_^4&vgJIcgB`6?FQ-sV7!e+Xo3=fe1JRDK_?ga zBT?t3wjkrkIFTn~N8W@v1|%ObHC%!!S!+1o@#wDh(Abz`ps_y6s``_;yo_I`R5FwK z#xB>+?JJ|E&t96Q+RdVNK$%Q>k|0u5vTOMNY27^=NYH;3tlu_29T2Co1%EF{O zw2xn*Oc^Mtp~Ku$H4y})b$?IU-?Q!?DEkMN#M|aj#qC{ssS;>hO5e6cE0ON?$Y41# zxE|S8j_kW2{U+LAZdI+0JlZiISUt&(HH;oc+P*;I@hqLr+4DI?9j7^*2)`2^Ef(bMJeWORBe%Nuw`-IYxvHNm7V~&b zqtm%5Rnp`0%uc(yH8RxW9bx(Jtp8&)l6Gx+CeYr$#@IIaGYa(R8vp!%ob4#tNK5Zf zsL1SIV6aG`YGHkmn>sD4v&Cszoez6E7B4bwkbzb*D`O1U=TZ5J!u#L>r#B32S-K6* zt!bEsh7nksu9|_78V|hwTAfCXpsBZJ(x?C$T54vET0khe z?L@mt5X3)iOuRKohWZ-GYTwf~S5?SnPcG1M%h@c}k>C67B_B_-YTSFeIK7aU_u+47 fijd`+pit962!8_6Ux=mO?-y2{W@BZ#N_nv$2Ip_LEN8UMQUNMG5CJ%TR6C+0I+YVf^&4g~tRFRx zScqlBN~~(%FlrmI6MF!;PzcF3(Su}TzlJ%55OJK;Tzs?Da3F^3{J~7=>lc4F|JcVc za0wkn1j`7#!i1A8vQ_3h=oEhn6JEB-Jj39fT^3ZN^&$2rJLpB+R&J5CYq~nwDeF`X z{ZWJ5D(e~USC@vx#irE*DYwZ6Roe*KE*V_iq%8sGmW}_#nPiVFsTO7!^~xrOBO41? zWHYgz)Wq8Bma*cw?4gWuTF#PlxvA{mt&2WQ9An$!Fn3A7ZHs@@zk@{yJmV0;E87;Y zh(F=5hbGP4J7jl{a8NVWj*t-rkfJF_vS&aOKoh-d8$l_v>=2*>UoZR0?eu+f7d(+xX9AlkAA!061Z!~BXd^xH(7S{t*2ho^CpB>X-oXcq zrhg5~xJLsw%)<=}6TCVXh~(x_S|dZvdHQ(Am`3IXG!FC>9y*24EUY$jvY?+ZtXrqF z>$4XODP7XI&Oi80Y`~;MVa6ywfCDI{|6g>yO4Lu7nD~`Xs6;>!1Q^ur4jklXF z>n<0>AUjXB$%3qvb+R7LfnDW8?GY>+2KX!nd#4)T&C~Lf3sQ}5HrFgtDLpv<%EQh-v=Tk&;prE&m%XqT;s4zW{XlBt*x~$%cR=lh zQc#vsoGcD-vayHTw9D6;yL<|dooS-+CwFKd-=za20-M|iubJ^gF`q9!o6i+aE70Xm zXXnTaj6D~Zi?d`Z8`#Lvz?oU46etu+fvIAll*ttW%G?>%j+`xn1tCnRDa6&yrQ%FF zpFNk&rwf@gS#q9@u^K|ofry!#xt;-B*A$gH|7<3Iny{#^^GcDF>H_$bB?PVs5%yA$ zX*Omg**w&wbJ;W$jVZ=h(lexZl9aQGlFnz2XY+(*Y0N~Rwj#o68JT55U3#OC)Ol9Z z>U!84^G>N7SP5H$Y$!bgZ@fBHUC0(nWKLaQF~L-U&8=AnUPQ3}dgP&}v*&Y*oE_3# z`5A~8(5fAIeV2<%zMWOy&fA_{cl@DBc>8Vtj%9%}nH!w2Rh&0ABovL!3wL6Px7u#B z{n)?MH(c!-t{gu4``Bbvip=l5JbcI1UcP+j6>)yJCbgU&dui;|sVljK-27No>Z(c3 z^9No!@TziUZeeczKvjzU!4tLuVLbGViq;i@CN@YQ95;B9Rn!PyE9wFj5H-BoY*we-@~C#zeZyzMRTyP&UGy-U_m z)f)P1C~`IXgZQ0jypq^mP3*fJeF}EuZ2gQA;Kg+!cW@@C0Nl1Ne6@hi$1kM(M22!3;=lL5P(ax5NV?y3How@OL6P3@ie>~Db$Hlnsp|l zV~iPo;ifn<+Rnik{FCp&6p)GfjFO%yD!FgOc(MT0gA}TpH`3r-CO?~1Sl+OxY)0c1 z=+*r!7{@pLkn3qC%0`}yvN+rUsxP2V+*_91y;XPbZFk?i`D05*%@M3f!H;*1T&)>p`eT)Y$3W8kk%B;82W$FXWCSM{?OR+JA6ZO9qgeY>pvl{Gyeotk z={LK2@G*L$>!$OU%q=a+mS#zTuvaanB`4_fF|U@5kel@N*kD=lE8U_`L$Qx-GRVX1}sA<$N zBT+PeYGL&1QTlwMeeW(I1T%QHG;|Ef!tcAQel) diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index 09bed2e..64b234d 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from pathlib import Path import uuid @@ -136,8 +135,11 @@ class MoveTaskService: "source": item["source_absolute"], "destination": item["destination_absolute"], "kind": item["kind"], + "same_root": item["same_root"], "files": item["files"], "directories": item["directories"], + "progress_total_items": item["progress_total_items"], + "progress_label": item["progress_label"], } for item in items ], @@ -226,12 +228,12 @@ class MoveTaskService: details={"path": source, "destination": destination_relative}, ) + progress_label = resolved_source.absolute.name if source_is_directory: - directories, files = self._build_directory_plan( - resolved_source=resolved_source, - destination_root=destination_absolute, - include_root_prefix=include_root_prefix, - ) + files = [] + directories = [] + if include_root_prefix: + progress_label = resolved_source.absolute.name else: files = [ { @@ -241,6 +243,7 @@ class MoveTaskService: } ] directories = [] + progress_label = files[0]["label"] return { "source_relative": resolved_source.relative, @@ -252,6 +255,8 @@ class MoveTaskService: "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, "files": files, "directories": directories, + "progress_total_items": 1, + "progress_label": progress_label, } def _map_directory_validation(self, relative_path: str) -> None: @@ -271,70 +276,6 @@ class MoveTaskService: def _join_destination_base(destination_base: str, name: str) -> str: return f"{destination_base.rstrip('/')}/{name}" if destination_base.rstrip("/") else f"/{name}" - def _build_directory_plan( - self, - *, - resolved_source: ResolvedPath, - destination_root: Path, - include_root_prefix: bool, - ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: - directories: list[dict[str, str]] = [ - { - "source": str(resolved_source.absolute), - "destination": str(destination_root), - } - ] - files: list[dict[str, str]] = [] - for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): - root_path = Path(root) - dirnames.sort(key=str.lower) - filenames.sort(key=str.lower) - for name in dirnames: - 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_source.relative}, - ) - relative = entry.relative_to(resolved_source.absolute) - directories.append( - { - "source": str(entry), - "destination": str(destination_root / relative), - } - ) - for name in 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_source.relative}, - ) - relative = entry.relative_to(resolved_source.absolute) - files.append( - { - "source": str(entry), - "destination": str(destination_root / relative), - "label": self._progress_label( - top_level_name=resolved_source.absolute.name, - relative_path=relative, - include_root_prefix=include_root_prefix, - ), - } - ) - return directories, files - - @staticmethod - def _progress_label(*, top_level_name: str, relative_path: Path, include_root_prefix: bool) -> str: - relative_value = relative_path.as_posix() - if not relative_value: - return top_level_name - return f"{top_level_name}/{relative_value}" if include_root_prefix else relative_value - @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 e9ffe1d..9d424c6 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -298,26 +298,22 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) def _run_move_directory(self, task_id: str, item: dict[str, object]) -> None: - files = self._file_entries(item) - directories = self._directory_entries(item) - total_items = len(files) + total_items = int(item.get("progress_total_items", 1)) + source_path = self._item_source_path(item) + destination_path = self._item_destination_path(item) + current_item = str(item.get("progress_label") or Path(source_path).name) if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=total_items, - current_item=files[0]["label"] if files else None, + current_item=current_item, ): self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) return try: - completed_items = self._move_directory_files( - directories, - files, - task_id=task_id, - completed_items=0, - total_items=total_items, - ) + self._filesystem.move_directory(source=source_path, destination=destination_path) + completed_items = total_items if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return @@ -338,8 +334,8 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: - total_items = self._total_file_count(items) - current_item = self._first_file_label(items) + total_items = self._total_move_work_count(items) + current_item = self._first_move_item_label(items) if not self._repository.mark_running( task_id=task_id, done_items=0, @@ -501,6 +497,40 @@ class TaskRunner: return 0 return int(task["done_items"]) + @staticmethod + def _item_source_path(item: dict[str, object]) -> str: + value = item.get("source") + if isinstance(value, str): + return value + return str(item["source_absolute"]) + + @staticmethod + def _item_destination_path(item: dict[str, object]) -> str: + value = item.get("destination") + if isinstance(value, str): + return value + return str(item["destination_absolute"]) + + def _total_move_work_count(self, items: list[dict[str, object]]) -> int: + total = 0 + for item in items: + progress_total_items = item.get("progress_total_items") + if progress_total_items is not None: + total += int(progress_total_items) + continue + total += len(self._file_entries(item)) + return total + + def _first_move_item_label(self, items: list[dict[str, object]]) -> str | None: + for item in items: + progress_label = item.get("progress_label") + if isinstance(progress_label, str) and progress_label: + return progress_label + files = self._file_entries(item) + if files: + return files[0]["label"] + return None + def _copy_single_planned_file( self, task_id: str, @@ -607,44 +637,24 @@ class TaskRunner: completed_items: int, total_items: int, ) -> int: - return self._move_directory_files(self._directory_entries(item), self._file_entries(item), task_id=task_id, completed_items=completed_items, total_items=total_items) - - def _move_directory_files( - self, - directories: list[dict[str, str]], - files: list[dict[str, str]], - *, - task_id: str | None = None, - completed_items: int = 0, - total_items: int = 0, - ) -> int: - for directory in directories: - Path(directory["destination"]).mkdir(parents=True, exist_ok=True) - for file_entry in files: - if task_id is not None and self._is_cancel_requested(task_id): - return completed_items - if task_id is not None: - self._repository.update_progress( - task_id=task_id, - done_items=completed_items, - total_items=total_items, - current_item=file_entry["label"], - ) - self._filesystem.move_file(source=file_entry["source"], destination=file_entry["destination"]) - completed_items += 1 - if task_id is not None: - self._repository.update_progress( - task_id=task_id, - done_items=completed_items, - total_items=total_items, - current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), - ) - if task_id is not None and self._is_cancel_requested(task_id): - return completed_items - for directory in reversed(directories): - shutil.copystat(Path(directory["source"]), Path(directory["destination"]), follow_symlinks=False) - for directory in reversed(directories): - self._filesystem.delete_empty_directory(Path(directory["source"])) + progress_total_items = int(item.get("progress_total_items", 1)) + source_path = self._item_source_path(item) + destination_path = self._item_destination_path(item) + progress_label = str(item.get("progress_label") or Path(source_path).name) + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=progress_label, + ) + self._filesystem.move_directory(source=source_path, destination=destination_path) + completed_items += progress_total_items + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, progress_label), + ) return completed_items @staticmethod diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index ec103e59c9064136467864d72e319a02fb137b6e..835af5575c906d3c34ca7ee187c0b39628aba5e0 100644 GIT binary patch delta 3058 zcmbVOTWl0n7(VCB40~a>Q=v<5LbuRRAkOB@b!VypnhKOikQ9nA*xH@l?t|r01C$pM zy1N<@6hTOO3?i3kP!ojGn$814iXrjAV2CDQl*_ijg9t=P=R(1oaMq_xp~_M|?d^dsM)zOhtN^pX}=|TMerK3i6lvFZs9O zpZq#r;ytW~m6_|Lq#btIU$w)92!Xr%YYTtfF_&pXI2H~^jhH0FB4I;N#8|7K$D?t< z!iJ$}a;p+EEO6b)j7D2yMqCu)vaSn?+G+_AY{&vuS}jG#GX1Xxu4Zd$Ry1PCf@(#g zf?`=TGpa=eY*~t;7)GlWi-0S0?dC1;*kss1xK7yU^I~RZ!5;5V%rdV@tWLPtKHdp0 zx)AK6N9=)XGKF#N;ndZE0zkbWirX`8C;cpd>35PNWaY?t~699p$4>_etszp+wGn-?0|W5 z1#dVEQO1V zQIB+}P{m;K{%XXprDbHX24fT68rvWjxjA9v#@I*>)F3HbLZM$+iq!ZN2-+b4=rHPs z01YsRnMK^)=-c8-SPLUC0gu9SZ~)b!2uh%%=p4~{(TwMEz(P7UBpI9FI)SH9nx*S< zh{8WWUhYMOJBE@BEQvZz8VE9VqgZkd5z< zSBE8(4WC3vG(^LYOmHQWK~7e10lTReede3ej_A41(W52k5o=K;g0{OK{RYX=9#ra5 zRZ-H-H#1*?B+-KevN7{%c@LUNa1W{^clSXLDeFN+_PuY=w7bj#?lf_AGYb%R+Fsnv zZ0154@kJwgSXI@yfWs19epOQhL!m1h6Xl2~S&|Z06?n_pl1HUn)lPpOWBdEq2c;!p z*L09|8P`XyC0U;-NV}%nWfxiK%DXslc7SAx+nX-4`}A@;D=*l}`0h_$?-_JAPx_g; zP|(4xM6NOtrsgG@rZxsDlBLOVmi2ftURYOGhx1rCQhA%{=!&w_$%U8bu0G2`FLROwCTb?f?rUi-!Yy`hJt6pCE-? ze84o5!Y%A>a+2qKi-s`e1TwO54lY(zT^)-_HbOFu7@BD>ox*+LwL4~T@4^a7lnsKa*ahd<4LWziRp_TDWxWqU!5Y8eEAc+; ztu4Imtx1+8B2yYmE0O^ke=O*HR+GFnja^TI@3GYc|G@Z0JKHGUGnO4PL=j_ov}Up) zS(a4MOioXKFY=qAk^$YZJ({ zc?Q)|o5&U&uTf%sDw?ZLZGzfS9tF7G B63PGo delta 764 zcmYjPTS!z<6g_*NbLZYW&diy7KVlv_M2OmCYDQpAQllASW193rA?)!Hqlx`QjxPd( zN`#J;kWqwz+Bg%QpFW%+=p!uq(Wno{LMWn;KB~{|G!UJ?z4uyYud~((EoOy6S-0{{ z;T}TB(J@yIhI{5a9&s&)4;md37`NEVqFY>zJUZe$=MuI!W<+XPhvwu>$$87TWvV9X zv#UIMF?ze5%K4m>l=n zCsJUiAI);90V}&qG13h{L}nOYmIJuK2c0kg4OZ# z7eb&)0d-5&G3bugv8`R1+ODt;74{^?q*WByv;mul2NPM;vQSOi3ZY8{o7A9AJLfvV zS)^5(|M>cI?vL~1zH{HZbx6MRec5s^Gt(sD&vfEUWcn+oEpKKF?_iSj9ci1ynJRHv zWn8CvRoN}|=(!$ACn!x_tm*|B)Dm@xY9Q`mJY6kSeQNrQrJz-&Dl?W5cN@hX6Ou`g z8Obat11SS(SkE&NGsU0<$s#BVDNB$Q$toxtDO=D2qy>U(NH#%sB)cF7l0#4qQjQ=e zl2eci$t5TkDK{+8jp!D%5NV;HJfu89`AGSKJV+iv1xN*g3Xuv06(JP~Dn=?6v`AgV zy`eN;xt3*lH;+xj@5}tC56VJpTI(u5Da+M5DEiuC5N)%=nkI$GP}EYOlT{tGJednS zwx*d^$c0iXQ`7xs3HDby^{S#8p=D?xGr>D`KJfW$IjULBP%~AFnpG*+>F5bdQokO~ z4LNoCEgA61G8?nPvC?8-U$8^rQX}x?3*f?#9gZ%~foc4|F!Mi&|BYX@!_R#=CKCpf zR40YCu5*0b>zJLfre>sMT8SzLY%VK@w&7BjtC_!t;inZI?CkY-M>s!^CjT+49kkVw zz4Zhex5YboC>Re!VgVcpcEzK755GX`_LBUBjKzc9vpxb(2ZPVdW*g{>4g>?SU|(l{Jkk{#4?#^ueck`L>kZ5y7KVKl z`5SD`?>4;DaLkmjm5nH4iuJJN18$=v}{)-Gi&^N3thr%@0UKESW)g!`rfxX^zK3y5_&q#NS9#eRG%0k4f4{ zM6&M_eL!-P#NFx6_;dK(wWg%o1i7>$R6Ty(QY zXihZvVYADfI+pp7V6?f~oODkyCc(VvdDf)8v?(mtr?Q{#l$MGT9y;>(Y9Vhi(>K!hTi&Xb4p)qP9&XNHSZC^Rt zL|3=3!Y0ZLbmY+sA$kAP=RR*5orD8{SJ`V?;f^!1A=x4D{>~TKIW64P&)gNMjA^x> z%u13fnCk8`ULveTf&JkYIM$Xq^Bzxw4}u@EPvO0%);Z=BO)d&>wJ#l_Q9>`*EP369$M@7oeX_J$u=NcBSXv46pU& zUXOcTP+BY+9`ly_4x8_y9Xo&#pOfHppF=y{*UcQ|wCJZe_z@7z97b6*KRsD7gRCw$ zgrlqR)Ad$#fQkB?P`AtLUYFWHWqgUul_XUV+tp>z%NQ;Wqdi61XS-fz)?AuO-Jy%{ zdaTkt|K^aR36fiICw4@(%G$H>x0zRb7AQ;pIU5=WzG?7aQ2rO~=D<$MbnReW`NIO| zHX?aSK9l6PM56)fOP4b9a+rkkgK<`_E#G64yQtaud6NHrU_2jdn(6&iWgaSY8Hvbv z-cu1_mARj=+1O5*x4^IWKFb=kb^CgCYz#kx`G&>hs06MJe{A?Nov03KN)~CipL>&8 zpQNcg+P)3Rw-{bKP(S=Tvj0f(Cz20I{zdW`i9%a4NU}+CNZcgFB&8(PBy}V!NLG^ delta 2111 zcmaKsdu)?c6u`gxwf)*{tnAf2+OB(*Z5?i*2r4ibQ>O%B0^i0XP9|f6m9<-UTVRyO z%%T&70m+F5A4E-@4<1HxBOxaI0Y(T3Mo|<~j44P0NFb7-L{#*g+p*3`>>t1U?!D)p zd(XZ1>$TU_3j?ZkC^a=%!C%ebVPA0ZA?x6{-o;E&UQwD9&UA&VI^zbtL|5&~8Y4HN z8KjwXw{DcC=@ax)T_az^xLGgL%XRZ`%YrSBZW(SR;p`+iCmAhSS_)c^@yGOcjO+yLt#Pv$%$@9lSWOrETr8M<%7`8Ix=Gqkl;;v0^5(P4&{;PVqYu=1 zhNExQ?P3?j3r|#Qjvp{C%4+%h6#oI?N7?fzkHPtQRrm6YpWlLc`24NuF^ZuL@;!v# z6iBJxuijO}k^0q!`Y83CCu}6_BU~U{BrG9ZB78+SL-?BT4dE=}LnF75Um;wDz@n{M zF+R=EP6#f0P*mwdtdN$tlhhS%Z{wA%KF(tlI%+kjZOpaD%cqgCD?zAnQc65M4aeZ4 z#uv3Iw8kK=;Sddr=ctPkWsJ>Eb7U=P2(r|42@mJ!M!*bzwjm~$ zzT5(_B_?>FAq$rJFSF|~JMfro)Co|EY>f7pp|hp{&%wmX;b=gxPepfUj5)7Uz(|3S zmyQ+yfR$WDr~Ll6EOtSZty{-rc{%XOdbhng zF@YADNzJK*au`~_QoDv43d4f#BC)yWRc1@2p~P2o9Opzz;ATZ8%!p*$$9_bVQSSX) z=!ooAZ!_^%WIuD$EVWb%haloN=Ls=nianrzK~oH za&V{6?Xy{i|0-fo+Z$#X;&5-Kx+ None: + src_dir = self.root1 / "source-dir" + src_dir.mkdir() + real_dir = self.root1 / "real-dir" + real_dir.mkdir() + (real_dir / "nested.txt").write_text("hello", encoding="utf-8") + (src_dir / "link-dir").symlink_to(real_dir, target_is_directory=True) + target_parent = self.root1 / "target-parent" + target_parent.mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source-dir", "destination": "storage1/target-parent/moved-dir"}, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertTrue((self.root1 / "target-parent" / "moved-dir").is_dir()) + self.assertTrue((self.root1 / "target-parent" / "moved-dir" / "link-dir").is_symlink()) + self.assertFalse(src_dir.exists()) + def test_move_success_cross_root_create_task_shape_and_completed(self) -> None: src = self.root1 / "source.txt" src.write_text("hello", encoding="utf-8")