diff --git a/webui/backend/app/__pycache__/config.cpython-313.pyc b/webui/backend/app/__pycache__/config.cpython-313.pyc index 70f91da..6439911 100644 Binary files a/webui/backend/app/__pycache__/config.cpython-313.pyc and b/webui/backend/app/__pycache__/config.cpython-313.pyc differ 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 edb7b80..c1bb4be 100644 Binary files a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc differ 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 0000000..675cd66 Binary files /dev/null and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_task_schema_migration_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_task_schema_migration_golden.cpython-313.pyc new file mode 100644 index 0000000..0c089c7 Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_task_schema_migration_golden.cpython-313.pyc differ 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 df2bea2..4136f02 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_task_schema_migration_golden.py b/webui/backend/tests/golden/test_api_task_schema_migration_golden.py new file mode 100644 index 0000000..3454d42 --- /dev/null +++ b/webui/backend/tests/golden/test_api_task_schema_migration_golden.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app import dependencies +from backend.app.main import app + + +class TaskSchemaMigrationApiGoldenTest(unittest.TestCase): + def setUp(self) -> 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 0000000..0bd205a Binary files /dev/null and b/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc index dd9cc97..580570f 100644 Binary files a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc and b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/test_config.py b/webui/backend/tests/unit/test_config.py new file mode 100644 index 0000000..2171290 --- /dev/null +++ b/webui/backend/tests/unit/test_config.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.config import get_settings + + +class ConfigTest(unittest.TestCase): + def test_default_task_db_path_is_backend_data_absolute(self) -> 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;