mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: add progress indication for long downloads
This commit is contained in:
@@ -3,6 +3,7 @@ import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getFilesByTransactionId } from "@/models/files"
|
||||
import { incrementProgress, updateProgress } from "@/models/progress"
|
||||
import { getTransactions } from "@/models/transactions"
|
||||
import { format } from "@fast-csv/format"
|
||||
import { formatDate } from "date-fns"
|
||||
@@ -14,12 +15,14 @@ import { Readable } from "stream"
|
||||
|
||||
const TRANSACTIONS_CHUNK_SIZE = 300
|
||||
const FILES_CHUNK_SIZE = 50
|
||||
const PROGRESS_UPDATE_INTERVAL = 10 // files
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
|
||||
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
||||
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
||||
const progressId = url.searchParams.get("progressId")
|
||||
|
||||
const user = await getCurrentUser()
|
||||
const { transactions } = await getTransactions(user.id, filters)
|
||||
@@ -102,6 +105,11 @@ export async function GET(request: Request) {
|
||||
totalFilesToProcess += transactionFiles.length
|
||||
}
|
||||
|
||||
// Update progress with total files if progressId is provided
|
||||
if (progressId) {
|
||||
await updateProgress(user.id, progressId, { total: totalFilesToProcess })
|
||||
}
|
||||
|
||||
console.log(`Starting to process ${totalFilesToProcess} files in total`)
|
||||
|
||||
for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
|
||||
@@ -136,6 +144,11 @@ export async function GET(request: Request) {
|
||||
}${fileExtension}`,
|
||||
fileData
|
||||
)
|
||||
|
||||
// Update progress every PROGRESS_UPDATE_INTERVAL files
|
||||
if (progressId && totalFilesProcessed % PROGRESS_UPDATE_INTERVAL === 0) {
|
||||
await incrementProgress(user.id, progressId)
|
||||
}
|
||||
} else {
|
||||
console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`)
|
||||
}
|
||||
@@ -143,6 +156,11 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
if (progressId) {
|
||||
await updateProgress(user.id, progressId, { current: totalFilesToProcess })
|
||||
}
|
||||
|
||||
console.log(`Finished processing all ${totalFilesProcessed} files`)
|
||||
|
||||
// Generate zip with progress tracking
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
||||
import { incrementProgress, updateProgress } from "@/models/progress"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
@@ -8,10 +9,13 @@ import path from "path"
|
||||
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
const BACKUP_VERSION = "1.0"
|
||||
const PROGRESS_UPDATE_INTERVAL = 10 // files
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||
const url = new URL(request.url)
|
||||
const progressId = url.searchParams.get("progressId")
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
@@ -52,6 +56,13 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||
|
||||
// Update progress with total files if progressId is provided
|
||||
if (progressId) {
|
||||
await updateProgress(user.id, progressId, { total: uploadedFiles.length })
|
||||
}
|
||||
|
||||
let processedFiles = 0
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
// Check file size before reading
|
||||
@@ -62,22 +73,33 @@ export async function GET() {
|
||||
MAX_FILE_SIZE / 1024 / 1024
|
||||
}MB limit)`
|
||||
)
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(file)
|
||||
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
||||
|
||||
processedFiles++
|
||||
// Update progress every PROGRESS_UPDATE_INTERVAL files
|
||||
if (progressId && processedFiles % PROGRESS_UPDATE_INTERVAL === 0) {
|
||||
await incrementProgress(user.id, progressId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
if (progressId) {
|
||||
await updateProgress(user.id, progressId, { current: uploadedFiles.length })
|
||||
}
|
||||
|
||||
const archive = await zip.generateAsync({ type: "blob" })
|
||||
|
||||
return new NextResponse(archive, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="data.zip"`,
|
||||
"Content-Disposition": `attachment; filename="taxhacker-backup.zip"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,24 +3,57 @@
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { useDownload } from "@/hooks/use-download"
|
||||
import { useProgress } from "@/hooks/use-progress"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useActionState } from "react"
|
||||
import { restoreBackupAction } from "./actions"
|
||||
|
||||
export default function BackupSettingsPage() {
|
||||
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
|
||||
|
||||
const { isLoading, startProgress, progress } = useProgress({
|
||||
onError: (error) => {
|
||||
console.error("Backup progress error:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const { download, isDownloading } = useDownload({
|
||||
onError: (error) => {
|
||||
console.error("Download error:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const progressId = await startProgress("backup")
|
||||
const downloadUrl = `/settings/backups/data?progressId=${progressId || ""}`
|
||||
await download(downloadUrl, "taxhacker-backup.zip")
|
||||
} catch (error) {
|
||||
console.error("Failed to start backup:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Download backup</h1>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Link href="/settings/backups/data">
|
||||
<Button>
|
||||
<Download /> Download Data Archive
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={handleDownload} disabled={isLoading || isDownloading}>
|
||||
{isLoading ? (
|
||||
progress?.current ? (
|
||||
`Archiving ${progress.current}/${progress.total} files`
|
||||
) : (
|
||||
"Preparing backup..."
|
||||
)
|
||||
) : isDownloading ? (
|
||||
"Archive is created. Downloading..."
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2" /> Download Data Archive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground max-w-xl">
|
||||
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
|
||||
|
||||
64
app/api/progress/[progressId]/route.ts
Normal file
64
app/api/progress/[progressId]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getSession } from "@/lib/auth"
|
||||
import { getOrCreateProgress, getProgressById } from "@/models/progress"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
const POLL_INTERVAL_MS = 2000 // 2 seconds
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ progressId: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!session || !session.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { progressId } = await params
|
||||
const url = new URL(req.url)
|
||||
const type = url.searchParams.get("type") || "unknown"
|
||||
|
||||
await getOrCreateProgress(userId, progressId, type)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let lastSent: any = null
|
||||
let stopped = false
|
||||
|
||||
req.signal.addEventListener("abort", () => {
|
||||
stopped = true
|
||||
controller.close()
|
||||
})
|
||||
|
||||
while (!stopped) {
|
||||
const progress = await getProgressById(userId, progressId)
|
||||
if (!progress) {
|
||||
controller.enqueue(encoder.encode(`event: error\ndata: {"error":"Not found"}\n\n`))
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
// Only send if progress has changed
|
||||
if (JSON.stringify(progress) !== JSON.stringify(lastSent)) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`))
|
||||
lastSent = progress
|
||||
|
||||
// If progress is complete, close the connection
|
||||
if (progress.current === progress.total && progress.total > 0) {
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user