fix: copy and move
This commit is contained in:
Binary file not shown.
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -35,5 +36,8 @@ def _load_root_aliases() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
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)
|
return Settings(root_aliases=_load_root_aliases(), task_db_path=task_db_path)
|
||||||
|
|||||||
Binary file not shown.
@@ -8,6 +8,23 @@ from pathlib import Path
|
|||||||
|
|
||||||
VALID_STATUSES = {"queued", "running", "completed", "failed"}
|
VALID_STATUSES = {"queued", "running", "completed", "failed"}
|
||||||
VALID_OPERATIONS = {"copy", "move"}
|
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:
|
class TaskRepository:
|
||||||
@@ -197,6 +214,15 @@ class TaskRepository:
|
|||||||
ON tasks(created_at DESC)
|
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:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(self._db_path)
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -33,6 +33,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="left-items"', body)
|
self.assertIn('id="left-items"', body)
|
||||||
self.assertIn('id="right-items"', body)
|
self.assertIn('id="right-items"', body)
|
||||||
self.assertIn('id="mkdir-btn"', 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="left-breadcrumbs"', body)
|
||||||
self.assertIn('id="right-breadcrumbs"', body)
|
self.assertIn('id="right-breadcrumbs"', body)
|
||||||
self.assertNotIn('id="bookmarks-panel"', body)
|
self.assertNotIn('id="bookmarks-panel"', body)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+14
-14
@@ -15,6 +15,7 @@ let state = {
|
|||||||
},
|
},
|
||||||
activePane: "left",
|
activePane: "left",
|
||||||
selectedTaskId: null,
|
selectedTaskId: null,
|
||||||
|
lastTaskCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function paneState(pane) {
|
function paneState(pane) {
|
||||||
@@ -66,6 +67,15 @@ async function apiRequest(method, url, body) {
|
|||||||
return data;
|
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) {
|
function createButton(text, onClick) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.textContent = text;
|
button.textContent = text;
|
||||||
@@ -396,13 +406,7 @@ async function startCopySelected() {
|
|||||||
if (selectedItems.length === 0) {
|
if (selectedItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const baseDestination = window.prompt(
|
const baseDestination = paneState(destinationPane).currentPath;
|
||||||
"Copy destination base path (full path)",
|
|
||||||
paneState(destinationPane).currentPath,
|
|
||||||
);
|
|
||||||
if (!baseDestination) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
let successes = 0;
|
let successes = 0;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -418,6 +422,7 @@ async function startCopySelected() {
|
|||||||
destination,
|
destination,
|
||||||
});
|
});
|
||||||
state.selectedTaskId = result.task_id;
|
state.selectedTaskId = result.task_id;
|
||||||
|
await refreshTasksSnapshot();
|
||||||
successes += 1;
|
successes += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures += 1;
|
failures += 1;
|
||||||
@@ -437,13 +442,7 @@ async function startMoveSelected() {
|
|||||||
if (selectedItems.length === 0) {
|
if (selectedItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const baseDestination = window.prompt(
|
const baseDestination = paneState(destinationPane).currentPath;
|
||||||
"Move destination base path (full path)",
|
|
||||||
paneState(destinationPane).currentPath,
|
|
||||||
);
|
|
||||||
if (!baseDestination) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
let successes = 0;
|
let successes = 0;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -459,6 +458,7 @@ async function startMoveSelected() {
|
|||||||
destination,
|
destination,
|
||||||
});
|
});
|
||||||
state.selectedTaskId = result.task_id;
|
state.selectedTaskId = result.task_id;
|
||||||
|
await refreshTasksSnapshot();
|
||||||
successes += 1;
|
successes += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures += 1;
|
failures += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user