# Archive Upload + Copy-Path Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Checkbox steps. **Goal:** Let user copy archive path as Windows/Linux path, and upload videos + create date folders from the Video Archive page. **Architecture:** Two new library endpoints stream files into the archive (compose mount flips `:ro` → rw); project payload gains `video_root_linux`/`video_root_windows` derived from env; one new component `ArchiveControls.jsx` holds copy buttons + folder/upload UI; docs REQ-010/REQ-163 revised (user approved). **Tech Stack:** FastAPI + `library.py` path helpers, React 19, nginx proxy, Docker Compose. **Spec:** `docs/requirements.md` (REQ-010, REQ-163 revised; REQ-171, REQ-172 new), `docs/design.md` API table. ## Global Constraints - Commit only files named in your task brief; never `git add -A` (the tree carries unrelated dirty files) - Archive paths only via `library._safe_join` — no traversal (`backend/library.py:19`) - Date folders: `^\d{4}-\d{2}-\d{2}$`; extensions: `video.VIDEO_EXTS = (".mp4",".mkv",".mov",".avi",".webm",".m4v")` (`backend/video.py:15`) - No delete/rename UI (user decision) - New frontend file ≤400 lines; existing `LibraryPage.jsx` gets small diff only - UI checklist: no emoji, `cursor:pointer`, hover 150-300ms, focus visible, contrast ≥4.5:1, `prefers-reduced-motion`, responsive - Page copy is Indonesian-dominant → new button labels Indonesian - Verification = curl + browser (repo has no test framework) --- ### Task 1: Docs — REQ revisions + new REQs **Files:** - Modify: `docs/requirements.md` - Modify: `docs/design.md` (API table ~line 402-405 area, add rows) - Modify: `docs/tasks.md` (add 3 TODO entries) **Steps:** - [ ] Revise **REQ-010** → text: "The video archive lives at a local path (disk or mount). Videos may be copied in from disk **or uploaded through the browser** into an existing date folder (REQ-171)." - [ ] Revise **REQ-163** → "Nothing existing in the archive is moved, renamed or deleted; only user-initiated uploads and folder creation append to it." - [ ] Add **REQ-171** — "The Video Archive page can create a `YYYY-MM-DD` folder and upload videos into it. Uploads are streamed to disk, never overwrite an existing file, and only accept `video.VIDEO_EXTS`." - [ ] Add **REQ-172** — "The Video Archive page shows a button that copies the archive's host path to the clipboard, as a Linux path and as a Windows (`\\wsl.localhost\\...`) path when known." - [ ] Add design.md API rows: `POST /api/projects/{id}/library/dates?date=`, `POST /api/projects/{id}/library/upload?date=` (multipart `file`), project payload +`video_root_linux`,`video_root_windows` - [ ] Add tasks.md entries with below verification criteria, status `[TODO]` ### Task 2: Backend — host paths in project payload + env passthrough **Files:** - Modify: `backend/config.py` (add function after `VIDEO_ROOT`, line 23) - Modify: `backend/projects.py:180` (serializer dict) - Modify: `docker-compose.yml` (backend `environment:`) - Modify: `.env` (absolute `VIDEO_ARCHIVE_HOST`, add `WSL_DISTRO_NAME`), `.env.example` (same + comment) **Interfaces:** - Produces: `config.archive_host_paths() -> {"linux": str|None, "windows": str|None}`; payload keys `video_root_linux`, `video_root_windows` **Steps:** - [ ] Add to `backend/config.py`: ```python def archive_host_paths() -> dict: """Where the archive sits on the host — for the copy-path buttons (REQ-172). ponytail: relative VIDEO_ARCHIVE_HOST (./data/archive) cannot be resolved inside the container; set it absolute in .env or the buttons stay hidden.""" import re host = os.environ.get("VIDEO_ARCHIVE_HOST", "") if os.path.isabs(host): linux = host.rstrip("/") elif VIDEO_ROOT != "/videos": # bare-metal: VIDEO_ROOT is already the host path linux = VIDEO_ROOT else: return {"linux": None, "windows": None} if re.match(r"^[A-Za-z]:[\\/]", linux): return {"linux": None, "windows": linux.replace("/", "\\")} distro = os.environ.get("WSL_DISTRO_NAME", "") windows = f"\\\\wsl.localhost\\{distro}\\" + linux.lstrip("/") if distro else None return {"linux": linux, "windows": windows} ``` - [ ] In project serializer dict (`backend/projects.py:180`) add: ```python "video_root_linux": config.archive_host_paths()["linux"], "video_root_windows": config.archive_host_paths()["windows"], ``` - [ ] `docker-compose.yml` backend environment add: ```yaml VIDEO_ARCHIVE_HOST: ${VIDEO_ARCHIVE_HOST:-./data/archive} WSL_DISTRO_NAME: ${WSL_DISTRO_NAME:-} ``` - [ ] `.env`: `VIDEO_ARCHIVE_HOST=/home/araaraenjoyer/dbs_project/reTraining/data/archive`, add `WSL_DISTRO_NAME=Ubuntu`; mirror in `.env.example` with comment "must be absolute for the copy-path buttons (REQ-172)" - [ ] Verify: rebuild backend, `curl -s localhost:9010/api/projects/1` → keys present, windows value `\\wsl.localhost\Ubuntu\home\...` ### Task 3: Backend — mkdir + upload endpoints **Files:** - Modify: `backend/library.py` (after `list_videos`, ~line 99) - Modify: `backend/api/projects.py` (after line 151) **Interfaces:** - Consumes: `_safe_join`, `_effective_root`, `video.VIDEO_EXTS` - Produces: `library.LibraryConflict(LibraryError)`; `library.create_date(video_root, date) -> str`; `library.upload_video(video_root, date, filename, chunks) -> str` (returns `rel`); endpoints `POST /library/dates` → `{"date"}` (409 if exists), `POST /library/upload` → `{"rel"}` (409 exists, 400 bad ext/date/name) **Steps:** - [ ] Add to `backend/library.py`: ```python class LibraryConflict(LibraryError): pass def create_date(video_root: str, date: str) -> str: """Make a YYYY-MM-DD folder; never touch an existing one (REQ-171).""" if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): raise LibraryError("Folder name must be YYYY-MM-DD") folder = _safe_join(_effective_root(video_root), date) if os.path.exists(folder): raise LibraryConflict(f"Folder already exists: {date}") os.makedirs(folder) return folder def upload_video(video_root: str, date: str, filename: str, chunks) -> str: """Stream `chunks` into /; atomic via .part + rename.""" name = os.path.basename(filename or "") if not name or name != filename or not name.lower().endswith(video.VIDEO_EXTS): raise LibraryError(f"Unsupported file: {filename}") root = _effective_root(video_root) if not os.path.isdir(_safe_join(root, date)): raise LibraryError(f"No such date in the archive: {date}") dest = _safe_join(root, date, name) if os.path.exists(dest): raise LibraryConflict(f"Already exists: {date}/{name}") part = dest + ".part" try: with open(part, "wb") as handle: for chunk in chunks: handle.write(chunk) os.replace(part, dest) except BaseException: if os.path.exists(part): os.unlink(part) raise return f"{date}/{name}" ``` - [ ] Add endpoints to `backend/api/projects.py` (`File`, `UploadFile` already imported line 8): ```python @router.post("/{project_id}/library/dates") def create_library_date(project_id: int, date: str) -> dict: project = project_or_404(project_id) try: library.create_date(project["video_root"], date) except library.LibraryConflict as exc: raise HTTPException(409, str(exc)) except library.LibraryError as exc: raise HTTPException(400, str(exc)) return {"date": date} @router.post("/{project_id}/library/upload") def upload_library_video(project_id: int, date: str, file: UploadFile = File(...)) -> dict: project = project_or_404(project_id) try: rel = library.upload_video( project["video_root"], date, file.filename or "", iter(lambda: file.file.read(1 << 20), b"")) except library.LibraryConflict as exc: raise HTTPException(409, str(exc)) except library.LibraryError as exc: raise HTTPException(400, str(exc)) return {"rel": rel} ``` - [ ] Verify (deferred to integration phase — needs Docker rebuild after Tasks 2-4): mkdir 200/409/400, upload 200/409/400, list shows file, `.part` cleaned, shell `rm -rf` test folder afterwards ### Task 4: Infra — rw mount + nginx body size **Files:** - Modify: `docker-compose.yml` (archive volume line `...:/videos:ro` → drop `:ro`) - Modify: `frontend/nginx.conf:7` (`client_max_body_size 512m` → `20g`, update comment) **Steps:** - [ ] Edit both; build deferred to integration phase - [ ] Verify (integration): `docker inspect retraining-backend-1 --format '{{json .Mounts}}'` shows rw (no `"RO":true`); `curl -s localhost:9010/api/health` → 200 ### Task 5: Frontend — api helpers + ArchiveControls component + LibraryPage wiring **Files:** - Modify: `frontend/src/api.js` (after line 65) - Create: `frontend/src/components/ArchiveControls.jsx` - Modify: `frontend/src/pages/LibraryPage.jsx` (~line 474-498 header, ~line 399 load effect) **Interfaces:** - Consumes: payload keys `video_root_linux`, `video_root_windows`; endpoints Task 3 - Produces: `api.createLibraryDate(id, date)`, `api.uploadLibraryVideo(id, date, file, onProgress)`; component `` **Steps:** - [ ] `frontend/src/api.js`: ```js createLibraryDate: (id, date) => request(`/projects/${id}/library/dates?date=${encodeURIComponent(date)}`, { method: 'POST' }), uploadLibraryVideo: (id, date, file, onProgress) => new Promise((resolve, reject) => { const form = new FormData() form.append('file', file) const xhr = new XMLHttpRequest() xhr.open('POST', `/api/projects/${id}/library/upload?date=${encodeURIComponent(date)}`) xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) } xhr.onload = () => { let payload = {} try { payload = JSON.parse(xhr.responseText || '{}') } catch {} if (xhr.status >= 200 && xhr.status < 300) resolve(payload) else reject(new Error(payload.detail || `${xhr.status} ${xhr.statusText}`)) } xhr.onerror = () => reject(new Error('network error')) xhr.send(form) }), ``` - [ ] Create `frontend/src/components/ArchiveControls.jsx` (~170 lines) with: - `CopyPath`: two `btn btn-ghost` buttons rendered only when `project.video_root_linux` / `_windows` truthy; `navigator.clipboard.writeText` → label swap "Tersalin" 1.5s (`aria-live="polite"`); `title=` shows full path - `New folder`: `` + button → `api.createLibraryDate` → `onChanged()`; conflict (409) → error text - `Upload`: hidden ``; button "Unggah video"; sequential `api.uploadLibraryVideo` per file with thin progress bar (width % via inline style; CSS transition 200ms, disabled under `prefers-reduced-motion`); per-file error rows; `onChanged()` after each success - All: `cursor:pointer`, hover opacity/transition 200ms, visible `:focus-visible` outline (match existing `btn` styles), no emoji, 4.5:1 text - [ ] `LibraryPage.jsx` wiring: - add `const reloadArchive = useCallback(() => { api.archiveCycles(projectId).then(l => { setDates(l.cycles); if (!l.cycles.some(c => c.cycle === selected)) setSelected(l.cycles[0]?.cycle ?? null) }).catch(() => {}) }, [projectId, selected])` - render `` inside page-head right column above the truck-scan button (line ~484), stack vertically with `gap: 8` - [ ] Verify (integration phase): build frontend, browser checks + AGENTS §8 checklist ### Task 6: Docs close-out **Steps:** - [ ] Flip new `docs/tasks.md` entries to `[DONE]` with verification evidence - [ ] Re-read `docs/design.md` API table + disk layout — matches shipped behavior (no doc lies)