Add map import and export with durable refresh progress
This commit is contained in:
133
lib/map-transfer.ts
Normal file
133
lib/map-transfer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { api } from "./client-api";
|
||||
import type { ArchiveMetadata } from "../shared/map-archive";
|
||||
|
||||
type Progress = (message: string, values?: Record<string, number>) => void;
|
||||
|
||||
export async function exportMap(id: string, progress: Progress) {
|
||||
const {
|
||||
archiveMetadataSchema,
|
||||
archiveSchema,
|
||||
archiveLimit,
|
||||
archiveFileLimit,
|
||||
archiveManifestName,
|
||||
sha256,
|
||||
} = await import("../shared/map-archive");
|
||||
const { zipSync, strToU8 } = await import("fflate");
|
||||
const result = await api<{
|
||||
manifest: ArchiveMetadata;
|
||||
assets: { path: string; url: string }[];
|
||||
}>(`datasets/${id}/export`);
|
||||
const metadata = archiveMetadataSchema.parse(result.manifest);
|
||||
const files: Record<string, Uint8Array> = Object.create(null);
|
||||
const assets = [];
|
||||
let total = 0;
|
||||
for (const [index, asset] of result.assets.entries()) {
|
||||
progress("Exporting images: {done} / {total}", {
|
||||
done: index + 1,
|
||||
total: result.assets.length,
|
||||
});
|
||||
const response = await fetch(asset.url, {
|
||||
signal: AbortSignal.timeout(60000),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error("Could not download map images. Please try again.");
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.length;
|
||||
total += value.length;
|
||||
if (size > archiveFileLimit || total > archiveLimit - 128 * 1024) {
|
||||
await reader.cancel();
|
||||
throw new Error("Map archive exceeds the 256 MB limit");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
files[asset.path] = bytes;
|
||||
assets.push({ path: asset.path, bytes: size, sha256: await sha256(bytes) });
|
||||
}
|
||||
const archive = archiveSchema.parse({ ...metadata, assets });
|
||||
files[archiveManifestName] = strToU8(JSON.stringify(archive));
|
||||
progress("Preparing ZIP…");
|
||||
const zip = zipSync(files, { level: 0 });
|
||||
if (zip.length > archiveLimit)
|
||||
throw new Error("Map archive exceeds the 256 MB limit");
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([new Uint8Array(zip).buffer], { type: "application/zip" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${metadata.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "minegeo-map"}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
export async function importMap(file: File, progress: Progress) {
|
||||
const { archiveLimit, readMapArchive } = await import(
|
||||
"../shared/map-archive"
|
||||
);
|
||||
if (!file.name.toLowerCase().endsWith(".zip"))
|
||||
throw new Error("Choose a MineGeo ZIP file");
|
||||
if (file.size > archiveLimit)
|
||||
throw new Error("Map archive exceeds the 256 MB limit");
|
||||
progress("Checking map ZIP…");
|
||||
const { archive, files } = await readMapArchive(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
const { datasetId } = await api<{ datasetId: string }>("uploads/import", {
|
||||
manifest: archive,
|
||||
});
|
||||
const signed = await api<{
|
||||
assets: {
|
||||
path: string;
|
||||
objectName: string;
|
||||
token: string;
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
}[];
|
||||
}>("uploads/sign", { datasetId });
|
||||
const { createBrowserClient } = await import("@supabase/ssr");
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
try {
|
||||
for (const [index, asset] of signed.assets.entries()) {
|
||||
progress("Importing images: {done} / {total}", {
|
||||
done: index + 1,
|
||||
total: signed.assets.length,
|
||||
});
|
||||
const content = files.get(asset.path);
|
||||
if (!content) throw new Error("Invalid or incomplete map ZIP");
|
||||
const { error } = await client.storage
|
||||
.from("minegeo-raw")
|
||||
.uploadToSignedUrl(
|
||||
asset.objectName,
|
||||
asset.token,
|
||||
new Blob([new Uint8Array(content).buffer], { type: "image/webp" }),
|
||||
{ contentType: "image/webp" },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(
|
||||
"Map upload failed. Check your connection and import the ZIP again.",
|
||||
);
|
||||
}
|
||||
progress("Preparing your private draft…");
|
||||
await api("uploads/finalize", { datasetId });
|
||||
return datasetId;
|
||||
} catch (error) {
|
||||
await api(`datasets/${datasetId}/cancel`, {}).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user