Skip to content

Commit 1d9907d

Browse files
authored
feat: add wiki ZIP export endpoint and download buttons (#44)
* feat: add wiki ZIP export API endpoint GET /api/repos/{repo_id}/export returns a ZIP archive with pages organized as wiki/{page_type}/{target_path}.md. Includes tests for success and 404 cases. * feat: add ZIP download buttons to docs page and operations panel Add "Download ZIP" button next to "Export All" on the docs page, and an "Export" button in the operations panel. Both link to the new GET /api/repos/{repo_id}/export endpoint. * fix: use direct query in export to avoid list_pages 10k limit Reviewer feedback: crud.list_pages silently truncates at the limit arg. Use a direct select(Page) query instead so large repos export fully.
1 parent b874618 commit 1d9907d

4 files changed

Lines changed: 106 additions & 16 deletions

File tree

packages/server/src/repowise/server/routers/repos.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import io
67
import logging
8+
import zipfile
9+
from pathlib import PurePosixPath
710

811
from sqlalchemy import func, select
912
from sqlalchemy.ext.asyncio import AsyncSession
1013

1114
from fastapi import APIRouter, Depends, HTTPException, Request
15+
from fastapi.responses import StreamingResponse
1216
from repowise.core.persistence import crud
1317
from repowise.core.persistence.models import (
1418
DeadCodeFinding,
@@ -240,3 +244,42 @@ def _on_done(t: asyncio.Task) -> None:
240244
logger.error("background_job_failed", exc_info=t.exception())
241245

242246
task.add_done_callback(_on_done)
247+
248+
249+
@router.get("/{repo_id}/export")
250+
async def export_wiki(
251+
repo_id: str,
252+
session: AsyncSession = Depends(get_db_session), # noqa: B008
253+
) -> StreamingResponse:
254+
"""Export all wiki pages as a ZIP of markdown files with folder structure."""
255+
repo = await crud.get_repository(session, repo_id)
256+
if repo is None:
257+
raise HTTPException(status_code=404, detail="Repository not found")
258+
259+
pages = (await session.execute(select(Page).where(Page.repository_id == repo_id))).scalars().all()
260+
if not pages:
261+
raise HTTPException(status_code=404, detail="No pages to export")
262+
263+
buf = io.BytesIO()
264+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
265+
for page in pages:
266+
target = page.target_path or page.id
267+
safe = (
268+
target.replace("::", "/")
269+
.replace("->", "--")
270+
.replace("\\", "/")
271+
)
272+
path = PurePosixPath("wiki") / page.page_type / safe
273+
if path.suffix != ".md":
274+
path = path.with_suffix(path.suffix + ".md")
275+
276+
content = f"# {page.title}\n\n{page.content}"
277+
zf.writestr(str(path), content)
278+
279+
buf.seek(0)
280+
filename = f"{repo.name}-wiki.zip"
281+
return StreamingResponse(
282+
buf,
283+
media_type="application/zip",
284+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
285+
)

packages/web/src/app/repos/[id]/docs/page.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { use, useState } from "react";
4-
import { Download, Loader2 } from "lucide-react";
4+
import { Download, FolderArchive, Loader2 } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { DocsExplorer } from "@/components/docs/docs-explorer";
77
import { listAllPages } from "@/lib/api/pages";
@@ -41,20 +41,27 @@ export default function DocsPage({
4141
Browse AI-generated documentation for every file, module, and symbol.
4242
</p>
4343
</div>
44-
<Button
45-
variant="outline"
46-
size="sm"
47-
onClick={handleExportAll}
48-
disabled={isExporting}
49-
className="shrink-0"
50-
>
51-
{isExporting ? (
52-
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
53-
) : (
54-
<Download className="h-3.5 w-3.5 mr-1.5" />
55-
)}
56-
Export All
57-
</Button>
44+
<div className="flex gap-2 shrink-0">
45+
<Button
46+
variant="outline"
47+
size="sm"
48+
onClick={handleExportAll}
49+
disabled={isExporting}
50+
>
51+
{isExporting ? (
52+
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
53+
) : (
54+
<Download className="h-3.5 w-3.5 mr-1.5" />
55+
)}
56+
Export All
57+
</Button>
58+
<Button variant="outline" size="sm" asChild>
59+
<a href={`/api/repos/${repoId}/export`} download>
60+
<FolderArchive className="h-3.5 w-3.5 mr-1.5" />
61+
Download ZIP
62+
</a>
63+
</Button>
64+
</div>
5865
</div>
5966

6067
{/* Explorer */}

packages/web/src/components/repos/operations-panel.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from "react";
44
import { toast } from "sonner";
5-
import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react";
5+
import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle, Download } from "lucide-react";
66
import { syncRepo, fullResyncRepo } from "@/lib/api/repos";
77
import { Button } from "@/components/ui/button";
88
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -125,6 +125,16 @@ export function OperationsPanel({ repoId, repoName }: Props) {
125125
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
126126
{loading === "resync" ? "Starting…" : "Full Resync"}
127127
</Button>
128+
<Button
129+
variant="ghost"
130+
size="sm"
131+
asChild
132+
>
133+
<a href={`/api/repos/${repoId}/export`} download>
134+
<Download className="h-3.5 w-3.5 mr-1.5" />
135+
Export
136+
</a>
137+
</Button>
128138
</div>
129139
)}
130140
</CardContent>

tests/unit/server/test_repos.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,33 @@ async def test_full_resync_duplicate_returns_409(client: AsyncClient) -> None:
125125

126126
resp2 = await client.post(f"/api/repos/{repo['id']}/full-resync")
127127
assert resp2.status_code == 409
128+
129+
130+
131+
@pytest.mark.asyncio
132+
async def test_export_wiki_not_found(client: AsyncClient) -> None:
133+
resp = await client.get("/api/repos/nonexistent/export")
134+
assert resp.status_code == 404
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_export_wiki_returns_zip(client: AsyncClient, session) -> None:
139+
import zipfile
140+
from io import BytesIO
141+
142+
from repowise.core.persistence.crud import upsert_page, upsert_repository
143+
from tests.unit.persistence.helpers import make_page_kwargs
144+
145+
repo = await upsert_repository(session, name="export-test", local_path="/tmp/export-test")
146+
await upsert_page(session, **make_page_kwargs(repo.id))
147+
await session.commit()
148+
149+
resp = await client.get(f"/api/repos/{repo.id}/export")
150+
assert resp.status_code == 200
151+
assert resp.headers["content-type"] == "application/zip"
152+
153+
zf = zipfile.ZipFile(BytesIO(resp.content))
154+
names = zf.namelist()
155+
assert len(names) == 1
156+
assert names[0].startswith("wiki/")
157+
assert names[0].endswith(".md")

0 commit comments

Comments
 (0)