diff --git a/packages/server/src/repowise/server/routers/repos.py b/packages/server/src/repowise/server/routers/repos.py index d2a1ecd..bd616ce 100644 --- a/packages/server/src/repowise/server/routers/repos.py +++ b/packages/server/src/repowise/server/routers/repos.py @@ -3,12 +3,16 @@ from __future__ import annotations import asyncio +import io import logging +import zipfile +from pathlib import PurePosixPath from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse from repowise.core.persistence import crud from repowise.core.persistence.models import ( DeadCodeFinding, @@ -240,3 +244,42 @@ def _on_done(t: asyncio.Task) -> None: logger.error("background_job_failed", exc_info=t.exception()) task.add_done_callback(_on_done) + + +@router.get("/{repo_id}/export") +async def export_wiki( + repo_id: str, + session: AsyncSession = Depends(get_db_session), # noqa: B008 +) -> StreamingResponse: + """Export all wiki pages as a ZIP of markdown files with folder structure.""" + repo = await crud.get_repository(session, repo_id) + if repo is None: + raise HTTPException(status_code=404, detail="Repository not found") + + pages = (await session.execute(select(Page).where(Page.repository_id == repo_id))).scalars().all() + if not pages: + raise HTTPException(status_code=404, detail="No pages to export") + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for page in pages: + target = page.target_path or page.id + safe = ( + target.replace("::", "/") + .replace("->", "--") + .replace("\\", "/") + ) + path = PurePosixPath("wiki") / page.page_type / safe + if path.suffix != ".md": + path = path.with_suffix(path.suffix + ".md") + + content = f"# {page.title}\n\n{page.content}" + zf.writestr(str(path), content) + + buf.seek(0) + filename = f"{repo.name}-wiki.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/packages/web/src/app/repos/[id]/docs/page.tsx b/packages/web/src/app/repos/[id]/docs/page.tsx index e52d988..b075bc0 100644 --- a/packages/web/src/app/repos/[id]/docs/page.tsx +++ b/packages/web/src/app/repos/[id]/docs/page.tsx @@ -1,7 +1,7 @@ "use client"; import { use, useState } from "react"; -import { Download, Loader2 } from "lucide-react"; +import { Download, FolderArchive, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DocsExplorer } from "@/components/docs/docs-explorer"; import { listAllPages } from "@/lib/api/pages"; @@ -41,20 +41,27 @@ export default function DocsPage({ Browse AI-generated documentation for every file, module, and symbol.

- +
+ + +
{/* Explorer */} diff --git a/packages/web/src/components/repos/operations-panel.tsx b/packages/web/src/components/repos/operations-panel.tsx index 7a07d28..1b16421 100644 --- a/packages/web/src/components/repos/operations-panel.tsx +++ b/packages/web/src/components/repos/operations-panel.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { toast } from "sonner"; -import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react"; +import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle, Download } from "lucide-react"; import { syncRepo, fullResyncRepo } from "@/lib/api/repos"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -125,6 +125,16 @@ export function OperationsPanel({ repoId, repoName }: Props) { {loading === "resync" ? "Starting…" : "Full Resync"} + )} diff --git a/tests/unit/server/test_repos.py b/tests/unit/server/test_repos.py index d0bb20a..700b138 100644 --- a/tests/unit/server/test_repos.py +++ b/tests/unit/server/test_repos.py @@ -125,3 +125,33 @@ async def test_full_resync_duplicate_returns_409(client: AsyncClient) -> None: resp2 = await client.post(f"/api/repos/{repo['id']}/full-resync") assert resp2.status_code == 409 + + + +@pytest.mark.asyncio +async def test_export_wiki_not_found(client: AsyncClient) -> None: + resp = await client.get("/api/repos/nonexistent/export") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_export_wiki_returns_zip(client: AsyncClient, session) -> None: + import zipfile + from io import BytesIO + + from repowise.core.persistence.crud import upsert_page, upsert_repository + from tests.unit.persistence.helpers import make_page_kwargs + + repo = await upsert_repository(session, name="export-test", local_path="/tmp/export-test") + await upsert_page(session, **make_page_kwargs(repo.id)) + await session.commit() + + resp = await client.get(f"/api/repos/{repo.id}/export") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/zip" + + zf = zipfile.ZipFile(BytesIO(resp.content)) + names = zf.namelist() + assert len(names) == 1 + assert names[0].startswith("wiki/") + assert names[0].endswith(".md")