Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/server/src/repowise/server/routers/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}"'},
)
37 changes: 22 additions & 15 deletions packages/web/src/app/repos/[id]/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -41,20 +41,27 @@ export default function DocsPage({
Browse AI-generated documentation for every file, module, and symbol.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleExportAll}
disabled={isExporting}
className="shrink-0"
>
{isExporting ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
Export All
</Button>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleExportAll}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
Export All
</Button>
<Button variant="outline" size="sm" asChild>
<a href={`/api/repos/${repoId}/export`} download>
<FolderArchive className="h-3.5 w-3.5 mr-1.5" />
Download ZIP
</a>
</Button>
</div>
</div>

{/* Explorer */}
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/components/repos/operations-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +125,16 @@ export function OperationsPanel({ repoId, repoName }: Props) {
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{loading === "resync" ? "Starting…" : "Full Resync"}
</Button>
<Button
variant="ghost"
size="sm"
asChild
>
<a href={`/api/repos/${repoId}/export`} download>
<Download className="h-3.5 w-3.5 mr-1.5" />
Export
</a>
</Button>
</div>
)}
</CardContent>
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/server/test_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")