From 3e4761f5a7fbe0c606a9f0cf5e422e2b37a86147 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 16:21:00 +0100 Subject: [PATCH] Folder move added --- .../BATCH_DIRECTORY_MOVE_V1_DESIGN.md | 190 +++++++++++ project_docs/DIRECTORY_MOVE_V1_DESIGN.md | 308 ++++++++++++++++++ .../__pycache__/tasks_runner.cpython-313.pyc | Bin 4567 -> 5747 bytes .../task_repository.cpython-313.pyc | Bin 11998 -> 12703 bytes webui/backend/app/db/task_repository.py | 58 +++- .../filesystem_adapter.cpython-313.pyc | Bin 6190 -> 6402 bytes webui/backend/app/fs/filesystem_adapter.py | 3 + .../move_task_service.cpython-313.pyc | Bin 3988 -> 5566 bytes .../backend/app/services/move_task_service.py | 57 +++- webui/backend/app/tasks_runner.py | 33 ++ webui/backend/data/tasks.db | Bin 36864 -> 36864 bytes .../test_api_move_golden.cpython-313.pyc | Bin 13793 -> 19622 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 6076 -> 6459 bytes .../tests/golden/test_api_move_golden.py | 111 ++++++- .../tests/golden/test_ui_smoke_golden.py | 4 + .../test_move_task_service.cpython-313.pyc | Bin 0 -> 1890 bytes .../tests/unit/test_move_task_service.py | 27 ++ webui/html/app.js | 46 ++- 18 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 project_docs/BATCH_DIRECTORY_MOVE_V1_DESIGN.md create mode 100644 project_docs/DIRECTORY_MOVE_V1_DESIGN.md create mode 100644 webui/backend/tests/unit/__pycache__/test_move_task_service.cpython-313.pyc create mode 100644 webui/backend/tests/unit/test_move_task_service.py diff --git a/project_docs/BATCH_DIRECTORY_MOVE_V1_DESIGN.md b/project_docs/BATCH_DIRECTORY_MOVE_V1_DESIGN.md new file mode 100644 index 0000000..ffb0e7c --- /dev/null +++ b/project_docs/BATCH_DIRECTORY_MOVE_V1_DESIGN.md @@ -0,0 +1,190 @@ +# Batch Directory Move v1 + +## 1. Scope + +Doel van batch directory move v1 is het gecontroleerd ondersteunen van verplaatsingen voor meerdere geselecteerde items via de bestaande move/task-flow, zonder de huidige file-flow te destabiliseren. + +Aanbevolen v1-scope: +- alleen same-root batch move +- meerdere directories ondersteunen +- gemengde selectie van files + directories ondersteunen, maar alleen binnen dezelfde root +- geen cross-root batch directory move +- geen rollback +- geen recursive copy-delete move voor directories +- geen partial rename-semantiek in batchmodus + +Niet in scope: +- cross-root batch directory move +- batch move met meerdere roots tegelijk +- batch move met conflictoplossing UI +- cancel/retry +- rollback bij gedeeltelijk succes +- hernoemen per item binnen batch-popup + +## 2. Selectiesituaties + +### Meerdere directories +- Toegestaan in v1, mits alle geselecteerde directories binnen dezelfde root vallen. +- Bestemming is de current path van het inactieve paneel. +- Elk item krijgt als doelpad: `destination_base + / + item.name`. + +### Meerdere files + directories gemengd +- Toegestaan in v1, mits alle geselecteerde items binnen dezelfde root vallen. +- Files en directories worden in dezelfde batch verwerkt. +- Elk item gebruikt dezelfde destination-map semantiek. + +### 1 directory + meerdere files +- Valt onder gemengde selectie en is dus toegestaan onder dezelfde same-root-regels. + +### Niet toegestaan in v1 +- selectie die items uit verschillende roots combineert +- selectie waarbij voor een directory het doelpad in de eigen subtree valt +- selectie met symlink-source-items +- selectie waarbij een doelpad al bestaat + +## 3. Same-root versus cross-root + +Aanbevolen v1-keuze: +- batch directory move blijft beperkt tot same-root +- cross-root batch directory move wordt expliciet geblokkeerd + +Motivatie: +- same-root kan native rename/move gebruiken en sluit aan op de huidige directory move v1 +- cross-root voor directories vereist recursieve copy + delete en introduceert veel meer partial-failure-risico +- gemengde batch over roots maakt progress, foutafhandeling en rollback aanzienlijk complexer + +Geblokkeerde melding in v1: +- `Cross-root batch directory move is not supported in v1` + +## 4. Taskmodel + +Aanbevolen richting: +- één task voor de hele batch + +Motivatie: +- sluit beter aan op de gebruikersactie: één batch-confirmatie, één batchresultaat +- voorkomt een explosie van losse tasks bij grote selecties +- maakt batchprogress en foutsamenvatting eenvoudiger zichtbaar + +Progress: +- `done_items` / `total_items` zijn leidend +- `total_items` = aantal geselecteerde top-level items in de batch +- `done_items` telt per succesvol verplaatst top-level item op +- `done_bytes` / `total_bytes` blijven `null` in v1 voor batch directory move + - ook bij gemengde selectie is het verstandig in v1 item-gebaseerde progress leidend te houden + +Failure-semantiek: +- fail-fast binnen de batchtask +- bij eerste runtime-fout stopt verdere verwerking +- task eindigt als `failed` +- reeds verplaatste items blijven verplaatst +- geen rollback in v1 + +Partial completion model: +- partial completion wordt impliciet zichtbaar via `done_items < total_items` +- `failed_item` bevat het item waarop de batch stopte +- geen aparte `partial_completed` status in v1 + +## 5. Backend-impact + +Hergebruik: +- bestaande move-validatie voor individuele file/directory move +- bestaande task persistence en read-endpoints +- bestaande same-root native move in filesystem adapter +- bestaande path_guard containment en symlink-afwijzing + +Waarschijnlijke wijzigingen: +- move service: batchvalidatie en task-creatie voor meerdere items +- task runner: batch move worker +- filesystem adapter: hergebruik van bestaande file move en directory move primitives +- task repository: waarschijnlijk geen schemawijziging nodig als `done_items`, `total_items`, `current_item`, `failed_item` al volstaan + +Belangrijk semantisch behoud: +- rename voor exact 1 item blijft apart via bestaande rename-flow/F6-popupbeslislogica +- batchmodus is altijd move, nooit rename + +## 6. UI-impact + +Gedrag voor Move-knop en F6: +- bij meerdere geselecteerde items mag de bestaande batch move-confirmatie worden hergebruikt +- die confirmatie moet duidelijk tonen: + - aantal geselecteerde items + - destination-map + - dat batch same-root vereist voor directories +- voor gemengde selectie moet dezelfde batch-confirmatie bruikbaar blijven + +Benodigde meldingen: +- `Cross-root batch directory move is not supported in v1` +- `Batch move requires all selected items to be in the same root` +- `Destination already exists for one or more items` +- `Destination cannot be inside source` +- `Source must not be a symlink` + +Aanbeveling: +- hergebruik bestaande batch confirm-popup +- geen extra popup-type toevoegen in v1 +- alleen de validatietekst en submit-logica uitbreiden + +## 7. Security en validatie + +Per item moeten minimaal deze checks gelden: +- source ligt binnen whitelist +- destination-parent ligt binnen whitelist +- source is geen symlink +- destination bestaat nog niet +- directory destination ligt niet in subtree van source +- same-root verplicht voor directories in batch v1 + +Gemengde foutgevallen: +- als selectie meerdere roots bevat: directe validatiefout vóór task-creatie +- als één item ongeldig is: hele batchcreatie afwijzen vóór task-creatie +- geen "best effort" pre-validatie waarbij geldige items toch alvast starten + +Containment: +- path_guard blijft leidend voor alle bron- en doelpaden +- geen vrije pathconstructie buiten bestaande root mapping + +## 8. Teststrategie + +Golden tests: +- batch same-root directories success +- batch same-root mixed files + directories success +- batch cross-root directories blocked +- batch mixed-root selection blocked +- batch destination exists blocked +- batch destination inside source blocked +- batch symlink source blocked +- batch task failed shape bij runtime io_error + +Regressietests: +- exacte 1 file move-flow blijft ongewijzigd +- exacte 1 directory same-root move blijft werken +- batch file-only move blijft werken +- F6 rename/move voor exact 1 item blijft semantisch gelijk + +Securitytests: +- traversal in één van de source paths +- traversal in destination base +- symlink directory source +- symlink file source binnen gemengde selectie + +Runtime edge cases: +- eerste item succeeds, tweede faalt -> task failed met `done_items == 1` +- destination conflict ontdekt vóór task-creatie +- empty selection blijft no-op in UI, geen backend-aanroep + +## 9. Aanbeveling + +Aanbevolen v1-richting met laag regressierisico: +- ondersteun alleen same-root batch move +- ondersteun gemengde selectie van files + directories alleen als alle items in dezelfde root zitten +- gebruik één task per batch +- gebruik item-based progress (`done_items` / `total_items`) +- fail-fast zonder rollback +- hergebruik bestaande batch move-confirmatie in de UI + +Waarom deze richting: +- bouwt rechtstreeks voort op de huidige same-root directory move v1 en batch file move +- beperkt backendcomplexiteit tot validatie + batchrunner, zonder cross-root recursieve directorylogica +- houdt de UI voorspelbaar: één batchactie, één task, één resultaat +- minimaliseert regressierisico voor de bestaande single-item file-flow en F6-semantiek diff --git a/project_docs/DIRECTORY_MOVE_V1_DESIGN.md b/project_docs/DIRECTORY_MOVE_V1_DESIGN.md new file mode 100644 index 0000000..1c33931 --- /dev/null +++ b/project_docs/DIRECTORY_MOVE_V1_DESIGN.md @@ -0,0 +1,308 @@ +# DIRECTORY_MOVE_V1_DESIGN.md + +## 1. Scope + +Doel van Directory Move v1 is om directory-verplaatsing toe te voegen binnen het bestaande move/task/securitymodel, zonder het huidige veiligheidsniveau of de voorspelbaarheid van de backend te verzwakken. + +In scope voor v1: +- directory move als expliciete uitbreiding van de bestaande move-operatie +- task-based uitvoering +- conflictcontrole op destination +- beveiligde padvalidatie via bestaand `path_guard` +- duidelijke foutstatussen bij ongeldige of gevaarlijke moves + +Out of scope voor v1: +- geen rollbackmechanisme +- geen cancel/retry +- geen gedeeltelijke recovery van half-voltooide cross-root moves +- geen symlink-following buiten whitelist +- geen speciale merge- of overwrite-semantiek +- geen multi-select directory move als aparte batch-semantiek buiten bestaande sequentiële task-aanmaak + +--- + +## 2. Same-root versus cross-root + +### Directory move binnen dezelfde root + +Dit is de veiligste en simpelste stap. + +Kenmerken: +- kan meestal native via `rename()`/`move` op directoryniveau +- snel en atomair binnen hetzelfde filesystem/root +- geen inhoudelijke recursieve file copy nodig +- veel minder kans op partial failure + +### Directory move tussen verschillende roots + +Dit is aanzienlijk complexer. + +Kenmerken: +- vereist recursieve copy van directory tree +- daarna delete van source tree +- progressmeting en foutafhandeling worden lastiger +- partial failure is realistischer +- rollback is ingewikkeld en in v1 ongewenst + +### Aanbevolen scopekeuze + +Aanbevolen v1: +- **alleen same-root directory move ondersteunen** +- **cross-root directory move nog niet ondersteunen** + +Reden: +- sluit goed aan op bestaand taskmodel zonder grote complexiteitsexplosie +- beperkt regressierisico +- voorkomt een half-robuste recursieve copy+delete-implementatie zonder rollback + +--- + +## 3. Semantiek + +### Wanneer is het een echte move + +Directory move is van toepassing als: +- source een directory is +- destination een volledig doelpad is +- destination nog niet bestaat + +Binnen same-root: +- de operatie gebruikt native rename/move op directoryniveau +- dit geldt zowel voor verplaatsen naar andere parent als voor directory move binnen dezelfde root met andere naam + +### Destination bestaat al + +Gedrag v1: +- destination mag niet bestaan +- response: `already_exists` + +Geen merge in v1. + +### Nested destinations + +Verboden gevallen: +- source naar een child van zichzelf +- source naar een pad in eigen subtree + +Voorbeeld: +- source: `/Volumes/8TB/Shows` +- destination: `/Volumes/8TB/Shows/Archive/Shows` + +Dit moet direct geblokkeerd worden. + +Aanbevolen fout: +- `invalid_request` met duidelijke boodschap zoals `Destination cannot be inside source` + +### Verplaatsen van map in zichzelf + +Ook verboden: +- destination exact gelijk aan source +- destination onder source subtree + +Dit moet vóór task-creatie worden afgewezen. + +--- + +## 4. Taskmodel + +Directory move blijft task-based, ook voor same-root moves. + +### Progressmeting + +Voor same-root native directory move is echte byte-progress meestal niet beschikbaar. + +Aanbevolen v1: +- gebruik vooral `done_items` / `total_items` +- voor same-root kan dat minimaal zijn: + - `total_items = 1` + - `done_items = 0 -> 1` +- `done_bytes` / `total_bytes` mogen `null` blijven in v1 voor same-root directory move + +### Fail-fast gedrag + +Aanbevolen v1: +- fail-fast +- zodra de move-operatie faalt, task -> `failed` + +### Partial failure + +Voor same-root native directory move is partial failure veel minder waarschijnlijk. +- of de rename slaagt +- of de rename faalt + +Dus v1-model: +- geen expliciete partial-successstatus +- eindstatus blijft: + - `completed` + - `failed` + +### Rollback + +Geen rollback in v1. + +Voor same-root native rename is dat meestal niet nodig. +Voor cross-root zou rollback wel relevant worden, maar die scope wordt niet aanbevolen voor v1. + +--- + +## 5. Security + +### Symlinkgedrag in directory trees + +Aanbevolen v1-beleid: +- source directory zelf mag geen symlink zijn +- bij same-root native rename wordt de tree niet recursief gevolgd of gekopieerd, dus child-symlinks hoeven niet actief gevolgd te worden voor uitvoering +- toch blijft containmentcontrole op source en destination verplicht + +### Traversal en containment + +- bron- en destination-pad blijven via bestaand `path_guard` +- destination binnen whitelist verplicht +- destination mag niet buiten root escapen +- nested destination-in-source moet expliciet extra gevalideerd worden + +### PathGuard-impact + +Bestaand `path_guard` blijft basis voor: +- whitelistvalidatie +- traversalblokkade +- symlink containment check op bron/destination-resolutie + +Aanvullend nodig voor directory move: +- helper of servicecheck om te bepalen of destination in subtree van source ligt + +### Escapes buiten whitelist voorkomen + +Bij same-root-only v1: +- containment blijft relatief eenvoudig +- geen recursieve copy over child nodes nodig +- dus ook minder attack surface dan cross-root recursieve tree copy + +--- + +## 6. Backend-impact + +Waarschijnlijk te wijzigen delen: +- `webui/backend/app/services/move_task_service.py` +- `webui/backend/app/tasks_runner.py` +- `webui/backend/app/fs/filesystem_adapter.py` +- mogelijk `webui/backend/app/api/schemas.py` alleen als taskdetail-documentatie wordt aangescherpt +- mogelijk kleine aanvulling in `path_guard.py` of move-service validatiehelpers + +### Bestaande move-logica die hergebruikt kan worden + +Herbruikbaar: +- task-creatie +- repository-persistency +- statusflow `queued -> running -> completed/failed` +- destination-exists checks +- parent directory validation +- algemene error mapping + +Nieuw voor directory move: +- source typecheck moet directory toestaan in same-root-case +- same-root task runner moet directory rename kunnen uitvoeren +- nested-destination-validatie + +### Rename binnen zelfde parent blijft apart + +Ja. + +Aanbevolen scheiding: +- `rename` endpoint blijft aparte, directe single-path operatie +- `move` blijft task-based voor echte moves +- ook als een same-root directory move technisch op rename lijkt, blijft het semantisch onderdeel van `move` + +Dat houdt UI- en API-rollen duidelijk. + +--- + +## 7. UI-impact + +### Move-knop en F6 bij exact 1 directory + +Aanbevolen gedrag: +- F6 / Move-knop blijven dezelfde popupflow gebruiken +- exact 1 directory geselecteerd: + - zelfde parent + andere naam -> huidige `rename` route blijft toegestaan + - andere parent binnen dezelfde root -> task-based directory move toegestaan in v1 + - cross-root destination -> blokkeren met duidelijke melding, bijvoorbeeld: + - `Cross-root directory move is not supported in v1` + +### Multi-select met directories + +Aanbevolen v1: +- geen gemengde halfslimme batchflow +- als multi-select directories bevat: + - alleen toestaan als alle geselecteerde directories voldoen aan same-root move-semantiek, of + - eenvoudiger en veiliger voor v1: nog blokkeren met duidelijke melding + +Aanbevolen eerste stap: +- **multi-select met directories nog blokkeren** +- melding: + - `Batch directory move is not supported in v1` + +Reden: +- beperkt scope en regressierisico +- houdt UI-flow voorspelbaar + +--- + +## 8. Teststrategie + +### Golden tests + +Toe te voegen voor move-API: +- same-root directory move success +- directory destination exists -> `already_exists` +- directory source not found +- directory source is symlink -> blokkade +- nested destination blocked +- exact same source/destination blocked +- cross-root directory move blocked + +### Regressietests + +- file move success same-root blijft werken +- file move success cross-root blijft werken als huidige scope dat al ondersteunt +- rename endpoint blijft ongewijzigd +- browse en delete blijven ongewijzigd + +### Securitytests + +- traversal source +- traversal destination +- symlink source rejection +- destination inside source rejection +- destination outside whitelist rejection + +### Runtime edge cases + +- rename/move van lege directory +- rename/move van directory met inhoud binnen same-root +- permission failure -> `io_error` +- destination parent ontbreekt -> bestaande foutmapping + +--- + +## 9. Aanbeveling + +Aanbevolen v1-richting: +- **alleen same-root directory move ondersteunen** +- **cross-root directory move nog niet ondersteunen** + +Korte motivatie: +- laagste complexiteit +- beste aansluiting op bestaand taskmodel +- minimale partial-failure kans +- veel kleiner regressie- en securityrisico dan recursieve cross-root tree copy+delete + +Praktische v1-scope: +- exact 1 directory +- same-root move task-based +- nested destination geblokkeerd +- destination exists geblokkeerd +- cross-root directory move expliciet geweigerd +- batch directory move nog niet + +Dat is de veiligste eerste stap met duidelijke semantiek en goede testbaarheid. diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index d152c9d92e27dc58876470471de843d81edeff42..b700bb7631712a69411bd9f87a946935c0bdeee4 100644 GIT binary patch delta 1345 zcma)5O>9(E6u$TU&3iNNy?NsU`ZvEtYaRpBlK#*E!FCix(1!FfU?Rx??JFs?GvUsZ zz{1d&5EBy@dZQboaYcd~C0($=1ul$n)w-E+V&dAwMWqOdZan9<(Pm)5o#cDp{qA?p zeRIxv&D|S&-3`Zy5?G(TcXxix|JeO;;6vDbO3o2Z8Q}_}TxBt)^peXO*O<*z$@EX? z4&hNfrKnc5vL^28J48`S_xTmRRIM!5u7#D^c^=L*YWxOILS&$eZ=9X`azN`kKo%GG zfVFpp)vHV4QrHpJ&WZb4cj3P@0~op=V43`y=xru)KPPf6zpLrz*Zh3zg`TetttJLn zsNSy_=;8$tMACFn9FJ7#xVRU2%NYmxj>=PbIw(A|%z9XZQi} zhdD|MEB#hh?Jb}Y)80{d8hHlc5P|byh3`z9 zJ&3MX5Dp_u0t7K0Lw*#&254Kdgmd!?A%6qKV<^UE!-cRBR-}wA;l~l)L^vV7c3zZk z&rjn1fcV83qth#%`!-F@Z0{eAlS&BP=%+YHWib>D5@m|bEGtYcYFUy7bvYVX@9bt; zQOUw?RS!(?DDcw-HFjc&q4A*a5>qk%CY%}X#v!v@Wo;C4wO3XUGgF zXHmww2F7NKkD%}>!UVz@1T+O^XSLmqft4lSR$B~`^E-*P?U-M_2efwj^o9DB>7{Bl z}1o)f{ud}UPtko8_g_}=@cSGv`@7#8i_$z+Y z%j3;`2rnV*5fk1h{ajr0PU(i;^~e)Hct@;9@nPM4a+!9CNa7YfFFs0i(@@+_^v6#t M)O|=0xexLG0z@eu?*IS* delta 599 zcma)&%S#(k6vpRfCX;z&W^9wO5H*Ij24ebc^3zy!-kMDlpIp-d3|JnHyyKh;B#LwY} zf0YAg*FH_R_-$X>kQkv73sJ&ks?$(H`mQjA8dMf4yhq|NK*BMTD#B2C6c#FS8$B?o zDh!QAnEp-6M7w2jCU<|Uk&ybFKf=bLqa{fe{%UnH0mJ%R>ovEJWHy3n7Mgm|%A!o6 zt#Ic=-!ic#kp1=-mVa0TJa0^jf3kd*w>iXNzbS;lds? zmryPvO5iw8#nx;E?>Q(qd9vDVI`0S^$Fe_5=!%tv5_=1k!k~b7gP7;YL~lOl9zAJZ z9w?t1jq06tBhWmr{;B4BUSN1$t?{)9op??i!Ux8n7oQ|M5OLRF!!5{HP7V&;?ZkTw cV+j1NK%2$3s$XCqp*Z;1*(Aiil#rt68kos^X8-^I diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index c1bb4bed288574917bffb9036b04e4ffb501a765..94db469ce0ee4b8a51299e1809cbde6bd7475b3b 100644 GIT binary patch delta 1653 zcmai!U2NM_6vyv%J{;Sz<0N*Hx=rIWp#BrDYYHKuB3vV<~8@Mi~|B3e9kW z#FrE_5Cfz=RCf=&AXJ1j@z#-tX-G&QA$XgDkRSpy@ruNQp+!S`fphF+D`S+K=-j{0 zIrpF6^|f6PKURk20~*^O;L`(E#2}}G zr>P_KtHb10T_3Q7UR~_hEdy6;&^Bm^y`qFgYp7=%2J3JO5?M>?HAJmpD~j5Yt(+x$ zO0i5!@lTAm_dXA-0{|FFg)auM;;xQU)t)W&U{QRK25~VO?V+#e2+tz*`?6 zgl?T>8<|3cBWt|P&j;8yrKCV>h1%iTQYFly12aLc`pPFjbq}lR`Q54JX{1ji9R&YI z1cGU@Yx6i>p0dtJIEFc-X=${S5ezvPYA<@~@|- zeSSlOUvP8`Ll|~$Wh;G9z6i<2V-R2~D%&thQue*o%~v~f$2;mWThK;Q0u=j00pa0p zdbq+(T+tSN+%(|Yg3Z|hJ7|aOu+7^Mn^r^*{Wn1WaHwqV@;I!gE6g-RDzR~+ct?1= zWpECQ#{KfAAR8wj0DKDIM<0n2J=@iCU2V9l<@>_FggEHxQarIOCvH0)wA{!Z;sams z;vv2Lr*Snze{6qkzQobL+7B0Aq#RG%my72q7p3hZqrXxvL)(?TdpSD67RP4T`(tmk zLRB0k#d#3SoC6pV5N?PoYfbYtvkGs3lf>*f4Bn}D$~ziQrM&h=)9JUriJfmjCAbhnRf(wl5y#xVe>ul{ z&M^0!TPS^NRK7JT-x`%~jmo!1Ro-ds%8>GivE&n@`afIXjICnheZHHU%=LU=MxTt! zql%EspevvfalaBOcrJ6Uj7q^=43&zxI4byqGd(v~!@HqFZhMxp*2GWr0$bSkLi|{e O&e7kAH#uW)Qhx)veuG5- delta 1056 zcmZ{iT}TvB6vywK+1cHh-5qzwnb~jM{3y3vbHk|2ua&R|P0Y)Zh(aJy7HN(~(gz7e zSX3m3P(%m{qM%UOhwRBdd<@#hs1Tu-9(q`ELGY=2uUQs&AO7>3Gw06#&*41kTkcg? zJst(o<32dwKU6-iJ{N=)1h0kWA`&2u5ISqKWJ*p>3JDUKlM5!q$QcqM3V(HzFmZA6 zkO*;eQc09}I2H7Gi5f!tnGZdduSX+Aq=0?y@@kS?>w(8Ge`vy3DOPoU~0u2(OO8N3GN; zpaCf@b&4?W*X>=UU8x!yJTRK^1WmFJBy$H%(^_WVJ_!$DXCdC1b@sayh7UlKrh^)B z%L>vW&nmOtCP@1IMaEunR;hRN*=W<|kty&vk7=AT$(BYm3!4v3#pl!9m8_9yq((NM z8JemAIs(;@4*Q@=FM@19*(P0t{&{0WJe?A(RVQg__y`p`Co=?V;Zhe(7@aydcO~m> zW8+fH6cXPeC8auGL0iJv--bR|3Xx4y7n`BgP9-jb6)wXO7}q1P7_RUkjk8aDt2q7bRJU{nSW^-~eL^#CGv>Rv None: + def mark_running( + self, + task_id: str, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + current_item: str | None = None, + ) -> None: started_at = self._now_iso() with self._connection() as conn: conn.execute( """ UPDATE tasks - SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, current_item = ? + SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ? WHERE id = ? """, - ("running", started_at, done_bytes, total_bytes, current_item, task_id), + ("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id), ) - def update_progress(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None: + def update_progress( + self, + task_id: str, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + current_item: str | None = None, + ) -> None: with self._connection() as conn: conn.execute( """ UPDATE tasks - SET done_bytes = ?, total_bytes = ?, current_item = ? + SET done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ? WHERE id = ? """, - (done_bytes, total_bytes, current_item, task_id), + (done_bytes, total_bytes, done_items, total_items, current_item, task_id), ) - def mark_completed(self, task_id: str, done_bytes: int | None, total_bytes: int | None) -> None: + def mark_completed( + self, + task_id: str, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + ) -> None: finished_at = self._now_iso() with self._connection() as conn: conn.execute( """ UPDATE tasks - SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ? + SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ? WHERE id = ? """, - ("completed", finished_at, done_bytes, total_bytes, task_id), + ("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id), ) def mark_failed( @@ -168,16 +191,29 @@ class TaskRepository: failed_item: str | None, done_bytes: int | None, total_bytes: int | None, + done_items: int | None = None, + total_items: int | None = None, ) -> None: finished_at = self._now_iso() with self._connection() as conn: conn.execute( """ UPDATE tasks - SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ? + SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ? WHERE id = ? """, - ("failed", finished_at, error_code, error_message, failed_item, done_bytes, total_bytes, task_id), + ( + "failed", + finished_at, + error_code, + error_message, + failed_item, + done_bytes, + total_bytes, + done_items, + total_items, + task_id, + ), ) def _ensure_schema(self) -> None: diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 830ceb9d80359a5cd6f097a8dcce54b23e7557fe..47f54d95ab09d1f0ab47fd801dc2bb90a5993797 100644 GIT binary patch delta 765 zcmY+A%WD%+6vlIt$z$@E&Ln9@Q>`g3WPDI5R7FY!ZM8zN)l(?iVw+~B?bO6+Zl+a3 z+Ej7nLXm?gF5QR=tCEGdapO|(4+!{ASa2ocJ-5Z^EPnHybHDH0$M(qw1KLh16%UB- z$;Jm)OFh-zHD8J9SztE683cF`7!QFC!F*sv;R+ZKk}3>oP^5}L2EtNBg^EZOgDga) zQbi&rRa_`lsswaH4icnS7*B!;`yjbjrNA;_Kcx0bZMF;YpiwnQrxYL8p^FlBiM+-S`-I=pp?v#9w-?M1IMFD)QeqzUQ zEdG}D?iu{1kLx8N+aX@O!}HX_J}vItq)y?9m4CTw@nb?iB}=3U2M*_Uh+ zpLZ9Un-pmZSP?!<=1|S&{2X<+2%U9hcaO;5?gpu(fEAO`-9RTgFP7?ErDC~UTqrw* zM&XKg+xcf8tB2R{RL+=Rr9twYs-(6Ev~OFn-I6b+%gf1)Qab~j&}k4>5;Mxn7e$f8vn5PK-i!HQU=L5Ehv(q)@$o77DslPzh} zLO{^F3}W=?#gmHc!K)`P{s#-ChaN=`4S03lsWf(Ae)GIv&(7@A@`tO5-|=`<;w#no z)O@YINPO%Mc=S?Qkth&}1_;m~Ns>T8+LWnGQY0vpLUbYvrIIvJgbI-iQH2WgzK~E6 zuEIh^$r#C!DC$k1F)~haB=#@Tc$6oah$eu>R}$n5iDP&T@5UERk_1g{M2v!G!?zF^ zY&voqI&3deVyjV)$LZL6xWvB1KEoAnM;ip7?CHtb0NiJb`nVtEF!fpFOO%uT3z(_>En8tiSh1k3De_6_VZ zYrNRs#hxCAq0%{i_`!}#X$7-;h*4cWHdRluJE$}cBP>Rb2S%fhc&V4HS&n0^Id*Zs zxY*vZ|1*T1vfX#rr)K!PxUaB&jIIX4L7ZH|m+Q@%d#Y8uPc)ovrTcu6|A~>Qcm;ME zx*s;pEvsdlrXMv;*3Vrp-@p&uLfk=A5f-A2=y3R2$F None: Path(source).rename(Path(destination)) + def move_directory(self, source: str, destination: str) -> None: + Path(source).rename(Path(destination)) + def is_directory_empty(self, path: Path) -> bool: return not any(path.iterdir()) 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 dec6bc1d2166557e34d01d1e77cd0f9be29a977d..e06c762b8afa8a2edaa0579c41557fc2e57a1dda 100644 GIT binary patch delta 2850 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 j9G;38;}bOf8>*h5r4v*?L5nA-_Ck3N bool: + try: + destination.relative_to(source) + return True + except ValueError: + return False diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 8b3c0e3..2111370 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -35,6 +35,14 @@ class TaskRunner: ) thread.start() + def enqueue_move_directory(self, task_id: str, source: str, destination: str) -> None: + thread = threading.Thread( + target=self._run_move_directory, + args=(task_id, source, destination), + 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, @@ -123,3 +131,28 @@ class TaskRunner: done_bytes=progress["done"], total_bytes=total_bytes, ) + + def _run_move_directory(self, task_id: str, source: str, destination: str) -> None: + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=1, + current_item=source, + ) + + try: + self._filesystem.move_directory(source=source, destination=destination) + self._repository.mark_completed( + task_id=task_id, + done_items=1, + total_items=1, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_items=0, + total_items=1, + ) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 83e172ad51efe25cca5c214386bbfa66c571d44d..f33208e6b28dc6eee758983c0abf8a6b987b1157 100644 GIT binary patch delta 1532 zcma)+&1(}u7{)iPq`TWBRZu}&Y%YpPI_rGx&P>Ha31V-ewH_7(XLo0d^t-6w#WX$F zqU=o&TIxSgLcM!e@!-vadKA5Sch;t~X*QDM?DOo*kLUeZ-yT@s9(XyL+k7=v&u#8K zd^v3?bd;_HzI#cAtS;bUp8aM%1`T}m#NuawL`){1?j_`A3|GHmX)rpvd@ z#?`^uT&RRnhzSs376Zy77f9yDfFT-7Mm)q&?4(8#a_9w8fS5~d476K%Gz2Kpp9u~W zR~V#5Nf<PK+h?1QbAWhV|Q!0YN+gm?uc&A{b&NtN-4n`iMggVvIGULR3R~5QHJtegav@ zQ6)8cflFIEl`wzWv8N1s%9eK1-m%}?KUyXPc4K_7p2HA(01|-EJ>*UzI*G7@5yi;$ zot`>m0&^c^>X14JLnx3x(Nl+lV(3rRG7MxAK?f4-V(eElbx0hd?TvKEvsN|`_i3yl11iR*#zo6jcF=%h}~v@~!UU3Sl3=xqAq*|j4(%&*$$dhn=OG9`+I zVE)GWTAEwRVzpW7{Y+IyZrAQFFC^c#F6YqklJ-D4=+8_f#9fV)DPgLoR9Cs$Rps8J zZ6D2c@@b17M%A;`j*rI)hy#gINZpfsZAoyK=rk)`Y0AuvQtDhOn$J6t-kmOlqv6b! VJi>4{UHTW&OPKqu@{8{Se*xTLc4hzo delta 106 zcmV-w0G0oMpaOuP0+1U4e32YO0erDwq#qV237G&3GYRVq01cWCT@6SK%?@V_Qj;Jc zCI|{W4eSkZ4-2zYAWsOfkOBd-nJH@q1Sksu0J9V@Eees4%(DSI14Re|4IlsxlMNuV MArKS|vmlM&0v=i%Hvj+t diff --git a/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc index 4c8f2f0545f5b1b7ae5b9413c1dc69940c02b580..0dda84c23c81eaea0d8140adea35bd6b49ae88a1 100644 GIT binary patch delta 4016 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 E4}k`cp8x;= delta 788 zcmYk4T}V@57{||Zwln8;&eZAVm|tma^N=~MHl>^vhPg5b-iU-kWGF|u><=lA^iKOZlg$8+#?)bUeM zYy$h%PAn!q)Gaw4nX^w>IU}4DC=h{)1gMEPiRcwZ%+$=tWROJ4iP<0vsUR+5;XDGg zh`325Df(MkS>9@-iWwCfWMgDA$j-=akjzLnNFhNdQ4Dl2(_v5vqY{Hk8I>ATM#`vj zSW-ROg7nU%H8(^|5W}QzE6R;OoQYc?2)Y!fG(~lDEHxBIMZGLdu&1Rlr%vEtIE-Wa z?AX53jo*D0_$l0sP5uU~ZmY$lzq&^^4J9Z&#`1KM$ptx`NzuXKcx#aRGu*$(WOa`1 zp|^QC%S2Z)=f~m$vDBH<$wVxpqVBH~X%4TqRN?B5dQp$y{y;TH%Ow_1C$Xv7>s~3Z zoQ+IJmSyL%T(ey1`yB4g%TZhk?7@NVE%W=@4r>_zr%Rr1I%{@K}G1wii-kHb-mWI-3ua2YrdWu zD0*DD%Uc|e`r#z^gSX;g!Cs EKYfeZFaQ7m 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 b6847a397f84bbe1a60cdd6b64509c9313badb66..f9f49eb414ec2876c9edbf594469a75c26251781 100644 GIT binary patch delta 408 zcmdm^zuSoKGcPX}0}uo>Z_IS!-^f=j$P~vknU6(f^Ljx(MwU>9ApXg5tjdi1lbgWg zGBA0HRbld8Aw^EEVD4ZZOQt*)5Ra8Bm^YYbGOw^KBj4lzRt2F@)?j{9CXne23{5i6zMyL@Jg`E6qzT$;{7F0NLQ3TIp7lpBs=^lA%!mBs5K`jNl3~i~UlI zf!2U!i}Op1l2buU9fcI2aAsa2$P|!(=H@BF=8TMgC!Y~%u(`p()6d(mufij0Y$4i#1F> mE%uF(d-EZ2Peu_hMn=mS9M>f@FG^^BVF2+yL`{~LlmP%%rz24S diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py index 4d211fd..1d083e5 100644 --- a/webui/backend/tests/golden/test_api_move_golden.py +++ b/webui/backend/tests/golden/test_api_move_golden.py @@ -96,6 +96,33 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertTrue((self.root1 / "moved.txt").exists()) self.assertFalse(src.exists()) + def test_move_directory_success_same_root_and_completed(self) -> None: + src_dir = self.root1 / "source-dir" + src_dir.mkdir() + (src_dir / "nested.txt").write_text("hello", encoding="utf-8") + 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) + body = response.json() + self.assertEqual(body["status"], "queued") + + detail = self._wait_task(body["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertIsNone(detail["done_bytes"]) + self.assertIsNone(detail["total_bytes"]) + self.assertTrue((self.root1 / "target-parent" / "moved-dir").is_dir()) + self.assertTrue((self.root1 / "target-parent" / "moved-dir" / "nested.txt").exists()) + 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") @@ -116,6 +143,19 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertTrue((self.root2 / "moved.txt").exists()) self.assertFalse(src.exists()) + def test_move_directory_cross_root_blocked(self) -> None: + src_dir = self.root1 / "source-dir" + src_dir.mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source-dir", "destination": "storage2/source-dir"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_move_source_not_found(self) -> None: response = self._request( "POST", @@ -126,13 +166,24 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["error"]["code"], "path_not_found") - def test_move_source_is_directory_type_conflict(self) -> None: + def test_move_directory_source_not_found(self) -> None: + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/missing-dir", "destination": "storage1/out-dir"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_move_source_is_directory_type_conflict_for_file_destination_parent(self) -> None: (self.root1 / "dir").mkdir() + (self.root1 / "out.txt").write_text("x", encoding="utf-8") response = self._request( "POST", "/api/files/move", - {"source": "storage1/dir", "destination": "storage1/out.txt"}, + {"source": "storage1/dir", "destination": "storage1/out.txt/child"}, ) self.assertEqual(response.status_code, 409) @@ -151,6 +202,19 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"]["code"], "already_exists") + def test_move_directory_destination_exists_already_exists(self) -> None: + (self.root1 / "source-dir").mkdir() + (self.root1 / "target-dir").mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source-dir", "destination": "storage1/target-dir"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + def test_move_traversal_source(self) -> None: response = self._request( "POST", @@ -173,6 +237,33 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + def test_move_directory_destination_inside_source_blocked(self) -> None: + src_dir = self.root1 / "source-dir" + src_dir.mkdir() + (src_dir / "child").mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source-dir", "destination": "storage1/source-dir/child/moved-dir"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_move_directory_same_source_destination_blocked(self) -> None: + src_dir = self.root1 / "source-dir" + src_dir.mkdir() + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/source-dir", "destination": "storage1/source-dir"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_move_source_symlink_rejected(self) -> None: target = self.root1 / "real.txt" target.write_text("x", encoding="utf-8") @@ -188,6 +279,22 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"]["code"], "type_conflict") + def test_move_directory_source_symlink_rejected(self) -> None: + target = self.root1 / "real-dir" + target.mkdir() + (target / "nested.txt").write_text("x", encoding="utf-8") + link = self.root1 / "dir-link" + link.symlink_to(target, target_is_directory=True) + + response = self._request( + "POST", + "/api/files/move", + {"source": "storage1/dir-link", "destination": "storage1/out-dir"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + def test_move_runtime_io_error_failed_task_shape(self) -> None: src = self.root1 / "source.txt" src.write_text("hello", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index e735a93..92cf661 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -82,6 +82,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertTrue((static_root / "style.css").exists()) app_js = (static_root / "app.js").read_text(encoding="utf-8") 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("function rootKeyFromPath(path)", app_js) + self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js) app_js_url = app.url_path_for("ui", path="/app.js") style_css_url = app.url_path_for("ui", path="/style.css") diff --git a/webui/backend/tests/unit/__pycache__/test_move_task_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_move_task_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ebd16b8153b3473dffc495aedd593439a0a5085 GIT binary patch literal 1890 zcmb_c-)kF35T3m|oiCPL#da;psv$yz5D_djEhdl#2U1#!-MB?3hd>0&SzgIkOLyw- z$tHd%O`x^=)P!R2L;ir~v2S_opHM{&3fBYzp$~pD*ih(GXYX{4>bQkK7jAZDe|$GH z-^?D4jg0~h{q9fpSry==G@@aI3OURWfL$=bB#W>>ILS#}SyZ?Z%qmxLRu{E}7?0(k zs+1EE#H+cSe5FU42~{Hx)fj%K=I|eKl&8XZSk4WMa%y-r)J-jhN_2Zp30DITV~dG} zF+P@q4CKMovtW)?)$+{i7;_l896VV)Z5o$v0$39Ocs3ud56e76-sGU6G?c=reYssA ze%x{#*Rwp^b;J_d+UJ(HwlAU4NM3h0Sj7_SCgU4+jn#%R2Tqg2^SHVTRp7(~OfJF1 z>ZX!~FEp;9rUqKf)VSV=6-NBYW1tl#ynUJb+8~+1Pjr;%1v&Hr^jm-LnIuY^LzaoD z%*Fv}l+sa_p*%=RfH4raw8$z;)MFzLHzy(jB4kVw6tty3<0Y@vE(y28Ys`<;*6il0 zuX`46Ft32YA(B683BkBm;T^^$x}T!9pbo~wR_Q8Ewi6`xq3UabHS4}UfQt`p3h8w& zm-9a@ZMuA2w5=K|ZL*b?X?E<=id9=@&T7d+WKrrkwil?hg+x&gnWE^8 zq^j7y?HA>#lii^nI!x;>r$JP-jja3EPGIH@F~%?u@g9n=;o11qy;g7h?2i64HM6(b zO`ZEO(@kC6iN7!s-zUFIK1lX6^N%z0k2ZQ4^NCS8#9RMT16#PW3n$mW(5e#g_di#H zy2P=(U~4=m2iIF;y!6j1h=*11vDL)%%QE2d;ozrHMBMVX(eN++2PJiRJK#jvD^1&J zgsgn@hOC?nGbvqp7sW6!&r;c6-YND{rGS*ak?9(l2WR@zS07Jb{aNo#SDqNlJdKwg z!csx;bQG^L)P^vjOb9PA`1t=n997!Pw?O1XR~ zc@~8~MX8W4PmNlh$L*C4<^~VQr!M$ukTEXb^FYWaLcE3ITR2j**fRMep~Y^H@Tfc` z{b^aYLup|`dQ|VAx9Aa<-Q}y|w(t$vF_Hp*L{@y$UJ1Kc3+i9D9ofrx)Z-GYZU+I4 za3pS{q{UN{129n651*xBl-(@(X;_Vv5hzxnJy J1uYxo^Docg%Z&g4 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/unit/test_move_task_service.py b/webui/backend/tests/unit/test_move_task_service.py new file mode 100644 index 0000000..20d77c7 --- /dev/null +++ b/webui/backend/tests/unit/test_move_task_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.services.move_task_service import MoveTaskService + + +class MoveTaskServiceTest(unittest.TestCase): + def test_is_nested_destination_true_for_child_path(self) -> None: + source = Path("/tmp/source") + destination = source / "child" / "target" + + self.assertTrue(MoveTaskService._is_nested_destination(source, destination)) + + def test_is_nested_destination_false_for_sibling_path(self) -> None: + source = Path("/tmp/source") + destination = Path("/tmp/other/target") + + self.assertFalse(MoveTaskService._is_nested_destination(source, destination)) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index 8c7cd42..36aa2a4 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -259,6 +259,30 @@ function baseName(path) { return index >= 0 ? path.slice(index + 1) : path; } +function rootKeyFromPath(path) { + const normalized = (path || "").trim(); + if (!normalized) { + return null; + } + if (normalized.startsWith("/")) { + const segments = normalized.split("/").filter(Boolean); + if (segments.length < 2) { + return normalized; + } + return `/${segments[0]}/${segments[1]}`; + } + return normalized.split("/")[0]; +} + +function isNestedPath(sourcePath, destinationPath) { + const source = (sourcePath || "").replace(/\/+$/, ""); + const destination = (destinationPath || "").replace(/\/+$/, ""); + if (!source || !destination) { + return false; + } + return destination.startsWith(`${source}/`); +} + function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; @@ -892,6 +916,12 @@ function showDirectoryMoveNotSupported() { setStatus(message); } +function showBatchDirectoryMoveNotSupported() { + const message = "Batch directory move is not supported in v1"; + setError("actions-error", message); + setStatus(message); +} + function resetRenameMoveState() { renameMoveState = { source: null, @@ -965,7 +995,7 @@ function openF6Flow() { return openRenameMovePopup(); } if (selectedItems.some((item) => item.kind !== "file")) { - showDirectoryMoveNotSupported(); + showBatchDirectoryMoveNotSupported(); return true; } return openBatchMovePopup(selectedItems); @@ -991,9 +1021,17 @@ async function submitRenameMovePopup() { elements.error.textContent = "Destination must differ from source"; return; } - if (source.kind === "directory" && destinationParent !== sourceParent) { - elements.error.textContent = "Directory move is not supported in v1"; - return; + if (source.kind === "directory") { + if (isNestedPath(source.path, destination)) { + elements.error.textContent = "Destination cannot be inside source"; + return; + } + if (destinationParent !== sourceParent) { + if (rootKeyFromPath(destination) !== rootKeyFromPath(source.path)) { + elements.error.textContent = "Cross-root directory move is not supported in v1"; + return; + } + } } try {