feat(export): JSON full-backup download (pantry + saved recipes)

Adds GET /export/json that bundles inventory and saved recipes into a
single timestamped JSON file for data portability. The export envelope
includes schema version and export timestamp so future import logic can
handle version differences.

Frontend: new primary-styled JSON download button in the Export card with
a short description of what is included.

Closes #62
This commit is contained in:
pyr0ball 2026-04-16 09:16:33 -07:00
parent 2ad71f2636
commit c8fdc21c29
2 changed files with 43 additions and 2 deletions

View file

@ -1,9 +1,11 @@
"""Export endpoints — CSV/Excel of receipt and inventory data.""" """Export endpoints — CSV and JSON export of user data."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import csv import csv
import io import io
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
media_type="text/csv", media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=inventory.csv"}, headers={"Content-Disposition": "attachment; filename=inventory.csv"},
) )
@router.get("/json")
async def export_full_json(store: Store = Depends(get_store)):
"""Export full pantry inventory + saved recipes as a single JSON file.
Intended for data portability users can import this into another
Kiwi instance or keep it as an offline backup.
"""
inventory, saved = await asyncio.gather(
asyncio.to_thread(store.list_inventory),
asyncio.to_thread(store.get_saved_recipes, 1000, 0),
)
export_doc = {
"kiwi_export": {
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"inventory": [dict(row) for row in inventory],
"saved_recipes": [dict(row) for row in saved],
}
}
body = json.dumps(export_doc, default=str, indent=2)
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
return StreamingResponse(
iter([body]),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View file

@ -392,10 +392,14 @@
<!-- Export --> <!-- Export -->
<div class="card export-card"> <div class="card export-card">
<h2 class="section-title">Export</h2> <h2 class="section-title">Export</h2>
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)"> <div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button> <button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button> <button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
</div> </div>
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
</p>
</div> </div>
<!-- Edit Modal --> <!-- Edit Modal -->
@ -847,6 +851,11 @@ function exportExcel() {
window.open(`${apiUrl}/export/inventory/excel`, '_blank') window.open(`${apiUrl}/export/inventory/excel`, '_blank')
} }
function exportJSON() {
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
window.open(`${apiUrl}/export/json`, '_blank')
}
// Full date string for tooltip (accessible label) // Full date string for tooltip (accessible label)
function formatDateFull(dateStr: string): string { function formatDateFull(dateStr: string): string {
const date = new Date(dateStr) const date = new Date(dateStr)