Files
reTraining/docs/superpowers/plans/2026-09-28-archive-upload-copy-path.md
T

12 KiB

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\<distro>\...) 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:
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:
"video_root_linux": config.archive_host_paths()["linux"],
"video_root_windows": config.archive_host_paths()["windows"],
  • docker-compose.yml backend environment add:
      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:
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 <date>/<filename>; 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):
@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 <ArchiveControls project={project} projectId={projectId} onChanged={reloadArchive} />

Steps:

  • frontend/src/api.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: <input type="date"> + button → api.createLibraryDate → onChanged(); conflict (409) → error text
    • Upload: hidden <input type="file" multiple accept=".mp4,.mkv,.mov,.avi,.webm,.m4v">; 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 <ArchiveControls project={project} projectId={projectId} onChanged={reloadArchive} /> 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)