"use client"; import { useState } from "react"; import { CloudDownload, Download, FileArchive, FileCode, FileJson, Sheet, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { useToast } from "@/components/ui/use-toast"; import { exportAPI, exportPath, type ExportFormat } from "@/lib/api/export"; import { getApiErrorMessage } from "@/lib/api/error"; import { saveApiDownload } from "@/lib/download"; interface ExportRow { format: ExportFormat; icon: LucideIcon; title: string; description: string; } // One sentence per row, and each says what is *in* the file rather than what the format // is - a diver choosing between four downloads is asking "which one has my stuff in // it". Two of them come close to "all of it", and the difference between those two is the // only thing this copy has to get across: DiveJSON holds the logbook - everything the // account has as data but its dive drafts - and the archive holds that plus the drafts and // the files that were uploaded into it. Which is also // why the archive is the row that mentions the certification scans and the portrait - // the one thing here worth knowing *before* the file lands in a downloads folder, not // after. // // DiveJSON leads because it is the complete logbook and the app's own format; the two lossy // rows below it are for handing to something else. // // Three of the four also come *back* - DiveJSON, UDDF and the archive, through the Import // page, which reads every format the API's converter reads. The CSV row // deliberately stays silent, and that asymmetry is the point: a sentence about what one // format can do is read as a claim about the ones beside it, so "brings it back" on every // row would promise a CSV import that does not exist. // // This comment said "two of the four" and named UDDF as one of the two that could not // come back, which was true until the API grew a converter and then was not. That is the // ordinary way this card's copy goes wrong - see "A sentence about what one format lacks // is a claim about all of them" in DECISIONS.md. const EXPORT_ROWS: ExportRow[] = [ { format: "divejson", icon: FileJson, title: "DiveJSON", description: "Your whole logbook in one file: every dive with every recording that made it — each one's device, its files and its full sample profile — plus cylinders, sites, trips, courses, marine life, gear with its service history, your c-card records, your contacts, the people you dive with and your tags. Everything in the account except your dive drafts and the uploaded files themselves, which it names by digest. DiveJSON is the open dive-log format this project maintains, and this app is its reference implementation — so this is the one that comes back with nothing lost.", }, { format: "uddf", icon: FileCode, title: "UDDF", description: "Every dive with its sites, trips, gases, cylinders, gear and sample profile, plus your contacts — as dive bases, shops and the places you stayed — the people you dive with, as buddies, and your date of birth, phone, check-in email and insurance policies, in the open format Subsurface, MacDive and divelogs.de import. A dive recorded by two computers writes one profile here — the recording shown by default — because a UDDF dive carries one set of samples; the DiveJSON and the archive carry them all. This is the file to hand another program, and the Import page reads it back too — gear sets, service history, your courses, your c-cards, who came on a trip, the roles on a dive beyond buddy and guide, a dive's tags, waves, weather and boat name, a dive site's tags, water type, entry types and OpenStreetMap or Wikidata entries, a contact's place on the map, your emergency contacts and your policy numbers have no slot in it, and ride in the DiveJSON and the archive instead.", }, { format: "csv", icon: Sheet, title: "Spreadsheet", description: "One row per dive, flattened for Excel, Numbers or a notebook. The normalized set — cylinders, trips, sites, gear, service history, courses, certifications, contacts, people, tags — rides in the DiveJSON, and as its own CSVs inside the archive.", }, { format: "archive", icon: FileArchive, title: "Full archive", description: "Everything, as a zip: the DiveJSON, the UDDF, the full CSV set, every dive-computer file you uploaded — each under the recording it belongs to — every certification card image you uploaded, and your dive drafts, which an import leaves out. This is the one to keep as a backup — it is also the only download that can put those files back, since an import can restore only the bytes it actually carries. Your portrait is in it too, and importing the archive offers it back beside yours. The card scans and the portrait are personal documents, so treat the file as one.", }, ]; interface DataExportCardProps { // Only ever used to name the saved file, and only when the server's own name cannot // be read - see `exportFilename`. The endpoints take no user parameter at all. username: string; } // Four downloads in Settings → Data, each handing back the whole logbook. // The product's promise is that nothing in an account is reachable only through this // app, and this card is the half that can be checked by pressing a button. The Import // page is the other half - a copy nothing can read back is a copy in name only - and // the two together are what make the round trip a claim rather than an intention - for // everything but the dive drafts, which the archive carries and an import leaves out. // // Downloads go through `saveApiDownload` rather than a plain `` - the // endpoints need an `Authorization` header, which a link cannot send (the access token // lives in memory, not in a cookie). See `lib/download.ts`. export function DataExportCard({ username }: DataExportCardProps) { const { toast } = useToast(); // A *set* of formats, rather than a boolean or the single `ExportFormat | null` this // started as: the four rows run independently, so two concurrent downloads must not // clear each other's spinner. DECISIONS.md carries the argument and what each of the // simpler shapes gets wrong. const [busy, setBusy] = useState>(new Set()); const handleDownload = async (row: ExportRow) => { // The button is `aria-disabled` rather than `disabled` (see below), so it still // takes clicks - this is what makes the row actually inert while it is fetching. // A click is a discrete event, so React has flushed the previous one's `setBusy` // by the time a second handler reads this. // // What this prevents is a second *save*, not a second request: `apiClient.get` // dedupes in-flight GETs by url+params+responseType, so the duplicate click would // join the pending promise rather than spend another of the caller's ten hourly // exports. Both callers would then reach `downloadBlob` with the same bytes and // write the file twice - and pin a second copy of a possibly-large blob for // `REVOKE_DELAY_MS`. if (busy.has(row.format)) return; try { // Functional updates throughout, because concurrent downloads are the point: // two handlers reading the same stale `busy` would each write a set missing the // other's format. setBusy((current) => new Set(current).add(row.format)); await saveApiDownload(exportPath(row.format), () => exportAPI.download(row.format, username), ); } catch (error) { toast({ title: "Error", description: getApiErrorMessage( error, // `row.title`, not lowercased: "your UDDF" is the file's name, and // "your uddf" reads like a typo. `Could not export your ${row.title}. Please try again.`, ), variant: "destructive", }); } finally { setBusy((current) => { const next = new Set(current); next.delete(row.format); return next; }); } }; return ( Data export Take a complete copy of your logbook, in formats other programs can read. Nothing here is locked to OpenDiving.
    {EXPORT_ROWS.map((row) => { const Icon = row.icon; const isBusy = busy.has(row.format); return (
  • ); })}
); }