From 523395b92ab2d0135717a91631a12a4a792ffbc9 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 11:45:06 +0100 Subject: [PATCH] fix: copy and move --- .../app/__pycache__/config.cpython-313.pyc | Bin 1845 -> 2120 bytes webui/backend/app/config.py | 6 +- .../task_repository.cpython-313.pyc | Bin 10910 -> 11998 bytes webui/backend/app/db/task_repository.py | 26 ++++ webui/backend/data/tasks.db | Bin 0 -> 16384 bytes ...sk_schema_migration_golden.cpython-313.pyc | Bin 0 -> 9119 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 3934 -> 4064 bytes .../test_api_task_schema_migration_golden.py | 133 ++++++++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 2 + .../__pycache__/test_config.cpython-313.pyc | Bin 0 -> 2200 bytes .../test_task_repository.cpython-313.pyc | Bin 3071 -> 4346 bytes webui/backend/tests/unit/test_config.py | 31 ++++ .../tests/unit/test_task_repository.py | 28 ++++ webui/html/app.js | 28 ++-- 14 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 webui/backend/data/tasks.db create mode 100644 webui/backend/tests/golden/__pycache__/test_api_task_schema_migration_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_task_schema_migration_golden.py create mode 100644 webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc create mode 100644 webui/backend/tests/unit/test_config.py diff --git a/webui/backend/app/__pycache__/config.cpython-313.pyc b/webui/backend/app/__pycache__/config.cpython-313.pyc index 70f91dadc6d57a56c9239ed8513521284f378182..643991120584ca18c8abfef51b5262ce18089b79 100644 GIT binary patch delta 866 zcmZ8f&rcIU6rNdj+ikZk$Wka&*%n1yjT|H^))+}V8NeTOtkJY_v#<*kYaz2+6O9pW zdabMBpcfNwoQ%LlEI-gy|3sWl)J9K|~;-98Mw7!b6CN8pmP~(hdZoVGGg(c6*cG_#{mR4xje1$>Dyt#HWO_aMR~L;osw=n-&Jg|HAnVDq;}9Dt7(X_>QI> zTJvG|88`GG&we|y2o~UmaOWk!E{2_;Dk=s{v4DK+NEjS}8@_-#%;dY_i}WpAz-JC! zP2jA6slDE-7R5oUF9gGg=ic= zjZ&5hWl8&@t}m7=mah9@YT2um$Ce*nGpJQ{Y^pG?41+8KRyk!9s+`|vT8l=l;^^!b z-JI9gphLmyHfwVxL;FGau_epVZOd7A%GD*CUVyvPu9+{`UEfqIrte zQYLqlDNmVdU{AT$xa%o1+bf=Oua&;vSWz7I8CO`z`sFEKA0%~9c3MgxT zP_-{H4i_qhZCf^Fv%pw!mMi7?fVKlJPBM2!NKhDMBX}@$LG9v|N2_M7VqK+^=r}@b fjO=NEUtJjEHX8YfB5jmyqm;WJ(jo%R;~@ASnOd_F delta 571 zcmXv~&ubGw6rP#vk8Czul}$)nQJc_E7BvwhVnBoxOAFRuJE*Ub0cxAUK8tbdl3A$Z=kKK3q* ztU3C0v{YVeD<#4euGZ-+L8i?UuGg5XBcyg}2&g>G4W6km(746TdGbsFozL)W!qQ#l zoEyC_jlQ?39xc%$YDHgYIiq7cNYJC-w4mh@_mjzdM)T2uGOIcmv7^_@2bznj>Kr>D z(GF`4w`EUqwv97(CS{B8RGlpnM`^}|$1BSZ8qLP)%BI_D@cZt)W%psDwf5@b%b+vp zRXg79UeNcez88Ac&=Y&2;&&Vdatn|}a8$S~`4tHZ66DWAkh&b2ln9D^0rH4(wi|@5 z2*R+}?+Unvp)y`kK{zDGcK)dTwm!aeYkYfaYCrmEw~HX_H}(? zZj6-;kgIT5tU(YCpbv6N)O8&VDj1?;#2MFpGQe!$x{#6ysWdj_EW7)j5P<;Yv>tOM z$)xH;6rQm1?>v9u5B7t*a03%^AhC+@Cq*eeA*El$I3dNq8e!&}#R)A$x%5?o J(M_5>@gE>~c`E<_ diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py index bf58af2..5d50fc0 100644 --- a/webui/backend/app/config.py +++ b/webui/backend/app/config.py @@ -2,6 +2,7 @@ from __future__ import annotations import os from dataclasses import dataclass +from pathlib import Path @dataclass(frozen=True) @@ -35,5 +36,8 @@ def _load_root_aliases() -> dict[str, str]: def get_settings() -> Settings: - task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", "webui/backend/data/tasks.db").strip() + default_task_db_path = str(Path(__file__).resolve().parents[1] / "data" / "tasks.db") + task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", default_task_db_path).strip() + if not task_db_path: + task_db_path = default_task_db_path return Settings(root_aliases=_load_root_aliases(), task_db_path=task_db_path) 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 edb7b801e2d800d3d42ecae0b749d9bc34fbe336..c1bb4bed288574917bffb9036b04e4ffb501a765 100644 GIT binary patch delta 3111 zcmbtWO>7&-6`t8$krIEF6!rhVl5NqHEhUO1OR}BT)Q@ddf2oEvT-XM|kSm!9wUlP5 z{1671LsHm*fz((S%^@uUvA*JyHC&|=+*~7Tqg&v9g3f1 zHO#}j@bm5b{LJ~O;vE*+)? zZyoox6bouPlgghn^Zc*|aKUbjMcHg(e&*CtG&ZxikeFPYi_b5xl(TXNW#>h=z69br zIZrrcgiDNanbk3=mAs;Gh1D}zP*tpfDS~pbMph*#H)~=ppt==a&6=58Fl&IS7LqF`;wla_4_*fhB3d+ygnNLtE>tKFC1fOC7i zI^cD&;8VPMR?8b!LJ`eYV@dPXC4DVtX7V}y=GUEIDkTxCUHTR6D*Z`%Aa_HJ5w0R& zb@5sN+noosL?+E!VPpl|Ei`OJ+=uDD(i!)ZT;u_iM4f_~F zGmZ>2!H)r$3c!w;F}0VjmlEnP>Cs9c)ysN5bupREK4k>;ZH4DvH~-5@GT297D_sq4 z)95gI*)tV!i{)?;KqY%3XCgmhUGANDoR!@Q4bZnr4?@@Ik%}GHv+W2Iu00cwn@{S8 z>DAI->wiWE7l0r6W@X$OC$CQ_|GM!7`oq${8XwU~px zEr;n(tmT#hT`T>urGrXwVg;3YB`%Fn>kmyYmWJ9slatjPbHMij*sirC*Nr?M#6i{S ztLY4{B-Cz9tY%iAx9AD15Hpd=Wj9t0Ga@@-_M#b)5$p&e1mx_uj&=38J^|9aYNfi& z*#|!2uf69B-}Z%XD8KdfKRQNK?Vi8uuD|QC=>xf2eZv!B`SY-6V#Ez+t@2=?V4H6J z1&He;POU+Iz10@*QrWuI-8yVYajA}+kxglkm_VHdh1?L6uy`}lJmkkqG-4=*d=BeS zJl1F3?d>26>)!xhYiG{Juuzjg&qFYdk0O)fRVrah+@+EsWrRAq8kvV0N1H3)PlLZ9 zFOx{sLZSJkrRb^osFqK@oYfNx$Fi^x2!SI{5uF3t{v3h$Y66{+1(Y}3$ZHun3r!Fz3^x7$YVwvAtzy?nA9Xt)!Id=YFe`-1PyU6})s zPa8k2zDaNHyVK>SYF1K!V_8e?lgMqm# z3bZJ6yVbg_H7Rg={<-u)G_5ih`D(nfPfXT zwFU3422`mdirIuc5wP2mJp&@>Dn065mFY|LArUWerv43} Ch_`D1 delta 2061 zcmaJ>U1%d!6rP*OUy~+n(>6)dzqCzjr%keIx7BUyYP+qlvKH-aSJ1B6m`u}g(@ecH zbunA)iwLryTQB>%Ac(NOCK8m4Z+yfi{R zf;9m13g)9x-XaffcTk^rZ=^Bm7c4+KX`^7ll_na9N#k}|5^G**pmHv$jHu(qjdevc{!Fbwf8aQ)h4d9Dq={>M=!BeoMYZf|YIOo_wkuBvymIp|Y&0 z=_Si;=$kC3TK>Fhm|DpedQ6Cz(ejor$JDH;<}+E7U-Nd52>;ldB%#W#cTOG$Uo&fN z8cczh<)Bu2JzLcB@v5*3gsdC0IkY?*!M9NW{wrB?u@th4jt;80t>F2Yb7$u=bZKU3 zna)!dN7G(}L4+XyOD?FU;$otBCl<%XP=|?G8o+P?#PMMfzS}rQCM#WmX2(9wl#A($Rf#MmRSd$vlNF(+HCY z^#j#oHGo2#q_`)3yM!>Bi_^-$AU=zrq>XE<)0%(O^(#FTo z+bD)$*pCIp!9<_&CKRt-FLHai1;Y)uZ_3S7bCZ=UC#HiHoI%YM zgff8TT+mA@h{gH|R}`21XdT~*3$25dw^8UTYKHj|YAz_gBOW+omaM^S;N~3ofK^>Z zj}Cl>$!Y?O&b9!c@On-$rEj82?4DqC%OFc)esLZqdMyeSLoV1PYK6)F2ZDp3rATk{ za>#hCsF~_m`ix#GsX6=?($1%o6U$MI`Y^&Q!aTw= z2nz_$BP=1jfY63eK)`RDJ%^3>t=4kus=21;#TShw_?={D&)-I(2|W-3@H4*Pu2k9G z=L$Y}n((Ws*@0M1tpqGzCbPN;H?L+g40n;eg3!>V&6@H4Qjdom&9dtOTSweGv~GI@^tE{Q)evVQ>#=ciQw diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 8d3cfae..a0d26c8 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -8,6 +8,23 @@ from pathlib import Path VALID_STATUSES = {"queued", "running", "completed", "failed"} VALID_OPERATIONS = {"copy", "move"} +TASK_MIGRATION_COLUMNS: dict[str, str] = { + "operation": "TEXT NOT NULL DEFAULT 'copy'", + "status": "TEXT NOT NULL DEFAULT 'queued'", + "source": "TEXT NOT NULL DEFAULT ''", + "destination": "TEXT NOT NULL DEFAULT ''", + "done_bytes": "INTEGER NULL", + "total_bytes": "INTEGER NULL", + "done_items": "INTEGER NULL", + "total_items": "INTEGER NULL", + "current_item": "TEXT NULL", + "failed_item": "TEXT NULL", + "error_code": "TEXT NULL", + "error_message": "TEXT NULL", + "created_at": "TEXT NOT NULL", + "started_at": "TEXT NULL", + "finished_at": "TEXT NULL", +} class TaskRepository: @@ -197,6 +214,15 @@ class TaskRepository: ON tasks(created_at DESC) """ ) + self._migrate_tasks_columns(conn) + + def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None: + rows = conn.execute("PRAGMA table_info(tasks)").fetchall() + existing_columns = {row["name"] for row in rows} + for column, ddl in TASK_MIGRATION_COLUMNS.items(): + if column in existing_columns: + continue + conn.execute(f"ALTER TABLE tasks ADD COLUMN {column} {ddl}") def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self._db_path) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db new file mode 100644 index 0000000000000000000000000000000000000000..675cd662354ec714098de4e6a1831c7d7a03764b GIT binary patch literal 16384 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 Ya*nTCuzEh~`^&z+KDh{}dba!j1vDvcK>z>% literal 0 HcmV?d00001 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 df2bea2f1330d9757f6bf106b2ea87d70a1ca460..4136f02f1d008b87f89de9285d4f96635c748e48 100644 GIT binary patch delta 203 zcmca7_duTaGcPX}0}xC%*_iodBkzA^#v7Y?S$vo#$Fhn{&Set-(TseP^VpOb`9Y+h zKq#{*6G#UGg91Z3gQno*MQqa~cr#OMm6G!dDs_`e@|3E;+}!-K)Xhfhd5nx2lNWMa z6P=L1pyUdN&J7Npe%?-AC~tECrxG)x_U6evevGWbtj6E9Hb3L_V$@S*w4A|lT|)Ds ogyt6pAm>8>BLfRhN7ZF!$r~&@?Y>REt^OaF8Cax>#DFdV0N-Ib?EnA( delta 149 zcmaDLe@~A0GcPX}0}x~yZOojtk@r6{(*&l;{#+`Xjabx~CLd)JnVice0HOu?LYYmO zKuQ@H6d2MOH2EjLVw<*k9(x8OBhO@Z&TE^0a!NBZYHk+g^ None: + self.temp_dir = tempfile.TemporaryDirectory() + base = Path(self.temp_dir.name) + self.root1 = base / "root1" + self.root2 = base / "root2" + (self.root1 / "Shared_Folders" / "Downloads").mkdir(parents=True, exist_ok=True) + (self.root2 / "Shared_Folders" / "Downloads").mkdir(parents=True, exist_ok=True) + self.db_path = base / "tasks-legacy.db" + self._create_legacy_schema_db(self.db_path) + + self._orig_aliases = os.environ.get("WEBMANAGER_ROOT_ALIASES") + self._orig_db_path = os.environ.get("WEBMANAGER_TASK_DB_PATH") + os.environ["WEBMANAGER_ROOT_ALIASES"] = f"storage1={self.root1},storage2={self.root2}" + os.environ["WEBMANAGER_TASK_DB_PATH"] = str(self.db_path) + self._clear_dependency_caches() + + def tearDown(self) -> None: + app.dependency_overrides.clear() + if self._orig_aliases is None: + os.environ.pop("WEBMANAGER_ROOT_ALIASES", None) + else: + os.environ["WEBMANAGER_ROOT_ALIASES"] = self._orig_aliases + if self._orig_db_path is None: + os.environ.pop("WEBMANAGER_TASK_DB_PATH", None) + else: + os.environ["WEBMANAGER_TASK_DB_PATH"] = self._orig_db_path + self._clear_dependency_caches() + self.temp_dir.cleanup() + + @staticmethod + def _create_legacy_schema_db(db_path: Path) -> None: + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + operation TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + conn.close() + + @staticmethod + def _clear_dependency_caches() -> None: + dependencies.get_path_guard.cache_clear() + dependencies.get_task_repository.cache_clear() + dependencies.get_task_runner.cache_clear() + dependencies.get_bookmark_repository.cache_clear() + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "POST": + return await client.post(url, json=payload) + return await client.get(url) + + return asyncio.run(_run()) + + def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in {"completed", "failed"}: + return body + time.sleep(0.02) + self.fail("task did not reach terminal state in time") + + def test_move_task_creation_works_with_legacy_tasks_schema(self) -> None: + source = self.root1 / "Shared_Folders" / "Downloads" / "PLAN.md" + source.write_text("plan", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + { + "source": "storage1/Shared_Folders/Downloads/PLAN.md", + "destination": "storage2/Shared_Folders/Downloads/PLAN.md", + }, + ) + + self.assertEqual(response.status_code, 202) + task = self._wait_task(response.json()["task_id"]) + self.assertEqual(task["status"], "completed") + self.assertFalse(source.exists()) + self.assertTrue((self.root2 / "Shared_Folders" / "Downloads" / "PLAN.md").exists()) + + def test_copy_task_creation_works_with_legacy_tasks_schema(self) -> None: + source = self.root1 / "Shared_Folders" / "Downloads" / "COPY.md" + source.write_text("copy", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + { + "source": "storage1/Shared_Folders/Downloads/COPY.md", + "destination": "storage2/Shared_Folders/Downloads/COPY.md", + }, + ) + + self.assertEqual(response.status_code, 202) + task = self._wait_task(response.json()["task_id"]) + self.assertEqual(task["status"], "completed") + self.assertTrue(source.exists()) + self.assertTrue((self.root2 / "Shared_Folders" / "Downloads" / "COPY.md").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index f85ee70..bdd8120 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -33,6 +33,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="left-items"', body) self.assertIn('id="right-items"', body) self.assertIn('id="mkdir-btn"', body) + self.assertIn('id="copy-btn"', body) + self.assertIn('id="move-btn"', body) self.assertIn('id="left-breadcrumbs"', body) self.assertIn('id="right-breadcrumbs"', body) self.assertNotIn('id="bookmarks-panel"', body) diff --git a/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0bd205a8dff2bdba981316555939a31bd1506050 GIT binary patch literal 2200 zcmb_dO>7fK6n^_-dt)3sVL>*uCBZ?eT$Ds2gg}6_hN7ZG801Y_BqEL0_S!5sYj{@Sc6vX&*@(25S1>hM9!O+@_v^FRK+y(;-X$GbxDh(np&&X8vXN4;MtWs6X z%1mfFOv8gPujJ!_tL~id@A3g>zW;uU;^uL zxvlk)hGu%F<1{`9DM*76-VR1&Ud`|LyN_{^0UWe%zTeO$PXm~n0mR;2ZT)(hh5*eV zROE`B>3M<68HsN)9jE4+u3d9jPRQyh(>?nFT?-*rv0TC|*R`DrE3S9gK`gByu-h;X zR5CzhSQ6O{8S-{Ghxt`#sG?@VJbJX|lZySao1&MT|9sgt>*WU_0qYcM)eU=pc)*Pu{;(Kt3l&rGZsJdi*0K{gbV^1&_#4Lz-8m`3;cRPFJ z-<#u$%bpAJIjrbgmnWAdm#3Dd?xznn^+Su=svPObN-MGWjp^&tH*(i=_utxkxAY)( za3wi(>&oKsr-`JO7+C4syc}JMF2|SR_Xqah+x4*T{ipjs_*zD#F!-f!{8v4BM{A{q zz0`11A8G0PJbhnN-@mA>=*e%iTT?APs`4UIL2#vf|?eqT*MU+Rru{K2rRzkToM0r=^_fe-g6-$5=T z^DwpC26ecat2vf1272|mOOpTn=?4GUKg?90)e$j_*2Mb$lbt&C~mO2uAEcoV5HI10{ z`bfJ1{pR7?tZ;YK6ox*|G;UTAiyuZCB8I`WL+#v5(J>+7tH$brtSXWuJ%RKy=y?p$ z$B=jesi&Y^)2?dQ5?2#9rKPbY_s;QKS6azoFFD*yj<%8$UUK4|LP4tp;sx$?mUYoIX;<>`ch3FJ zcYW@;zW??9`@Hxd6cPX)-RHk4{UdCPF*vk+dL&BCvWQiIO3(UcIK(ADo>oq{Ivarz zH_b+(ATK0Ap2JVz93=SwD6*So;*=66gzfM9qT-B%qy&h8Bv5^EpmKRuNnlRIl_1~M zl@F`@D~kZ=VF2*VDOaZY+*hHp{2CU!1@Ztv6(CB5h*te76$fuKh{1^V5Q`DxAsk`iP_B6ktayjs)=-`WK}S? zhRIhJ@ROUHdy;88x>L1Vl22zD$kZLPpy^I0vt!{@hlf&lE7wKasaZ%bn&~t8sI%sf zfaj05{1lB&Vjen0%xPje-Bb`UBpM<{!luF5@2->_^Th^dSe1%tI1SERGmWZaHhhLv zE|;7JYphtdiO9)ABBE_{DV+%qrxV1XDl#<<;R^|%^X?DA=7kL5F@%L?NR+J9>Ks2&whGl1 z^CH66EFkxmy@pMbh7kS&lD`7+XZMr9rSWM9r{GTPKpw_r90AX5E>eIAZmjRo@*xxa QX#Ruw4;Pv=VB((s{|oDKjsO4v delta 465 zcmY+A%_~Gv7{=fCe$UlCW{i(Jl*X8n$!D=3u@U)L-9uSi-A0DS*KqF4!qP_BnNG@I zU}Yix0OT)Fq^4wHB^zZ*Y&iF_@z#0!Jw4A;Z|AG+yNSG8mI-3I=FjpgdYRamEO&HG z$TRA+#&$1Y#>HdxbyktEoV0;)9w z)m_DD<=0X?A^+xB)QwSNcmW_=2O!)u@58Ny5#b(z3k)eRi6WD!L8WF`Q None: + original = os.environ.get("WEBMANAGER_TASK_DB_PATH") + try: + os.environ.pop("WEBMANAGER_TASK_DB_PATH", None) + settings = get_settings() + finally: + if original is None: + os.environ.pop("WEBMANAGER_TASK_DB_PATH", None) + else: + os.environ["WEBMANAGER_TASK_DB_PATH"] = original + + resolved = Path(settings.task_db_path).resolve() + expected = Path(__file__).resolve().parents[3] / "backend" / "data" / "tasks.db" + self.assertEqual(resolved, expected.resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index 0eaf727..22d443a 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sqlite3 import sys import tempfile import unittest @@ -58,6 +59,33 @@ class TaskRepositoryTest(unittest.TestCase): } ) + def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: + legacy_db_path = Path(self.temp_dir.name) / "legacy.db" + conn = sqlite3.connect(legacy_db_path) + conn.execute( + """ + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + operation TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + conn.close() + + repo = TaskRepository(str(legacy_db_path)) + created = repo.create_task( + operation="move", + source="storage1/a.txt", + destination="storage2/a.txt", + ) + + self.assertEqual(created["operation"], "move") + self.assertEqual(created["source"], "storage1/a.txt") + self.assertEqual(created["destination"], "storage2/a.txt") + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index d0cb5be..d3a821f 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -15,6 +15,7 @@ let state = { }, activePane: "left", selectedTaskId: null, + lastTaskCount: 0, }; function paneState(pane) { @@ -66,6 +67,15 @@ async function apiRequest(method, url, body) { return data; } +async function refreshTasksSnapshot() { + try { + const data = await apiRequest("GET", "/api/tasks"); + state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; + } catch (_) { + // Task list panel is not visible in current UI; silently keep flow stable. + } +} + function createButton(text, onClick) { const button = document.createElement("button"); button.textContent = text; @@ -396,13 +406,7 @@ async function startCopySelected() { if (selectedItems.length === 0) { return; } - const baseDestination = window.prompt( - "Copy destination base path (full path)", - paneState(destinationPane).currentPath, - ); - if (!baseDestination) { - return; - } + const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); let successes = 0; let failures = 0; @@ -418,6 +422,7 @@ async function startCopySelected() { destination, }); state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1; @@ -437,13 +442,7 @@ async function startMoveSelected() { if (selectedItems.length === 0) { return; } - const baseDestination = window.prompt( - "Move destination base path (full path)", - paneState(destinationPane).currentPath, - ); - if (!baseDestination) { - return; - } + const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); let successes = 0; let failures = 0; @@ -459,6 +458,7 @@ async function startMoveSelected() { destination, }); state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1;