src/components/dives/dive-file-import.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FieldPathValue, Path, UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { ButtonSpinner } from "@/components/ui/button-spinner";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useToast } from "@/components/ui/use-toast";
import {
divesAPI,
MAX_DIVE_FILE_SIZE,
ParsedDive,
ParsedDiveInferredField,
ParsedDiveMatch,
Recording,
RecordingDevice,
} from "@/lib/api/dives";
import {
formatDiveStartTime,
formatDurationForForm,
isDateOnlyStartTime,
normalizeParsedStartTime,
} from "@/lib/date-time";
import { getApiErrorMessage } from "@/lib/api/error";
import { DIVE_COMPUTER_FILE_ACCEPT } from "@/lib/api/logbook-import";
import { DiveFormValues } from "@/components/dives/dive-form-fields";
import {
DiveRecordingFiles,
type PendingDiveFile,
} from "@/components/dives/dive-recording-files";
import { DiveMixtureInput } from "@/lib/validations/dive";
import {
describeMixtureImport,
existingMixtureFor,
fillMixtures,
mergeMixture,
mixtureImportNotes,
NO_MIXTURE_NOTES,
type MixtureImportNotes,
} from "@/lib/dive-import";
import { isNonEmptyFieldValue } from "@/lib/dive-form-fields";
import {
diveFileRows,
recordingDeviceLabel,
sameDevice,
} from "@/lib/dive-recordings";
import { DiveFormSection } from "@/components/dives/dive-form-section";
import { withReturnTo } from "@/lib/return-to";
import { Info, Upload } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { IconTooltip } from "@/components/ui/tooltip";
// Applies the fields parsed from a dive-computer export file onto a dive
// form. Shared between the "new dive" and "edit dive" forms since both
// expose the same importable field set. Generic over `TFieldValues` (rather
// than `UseFormReturn<any>`) so the concrete create/edit form type is
// checked at the call site; field name literals below are cast to
// `Path<TFieldValues>` since react-hook-form can't verify a literal string
// against a still-generic `TFieldValues`.
// Sets a single named field on the form. `name`/`value` are checked against
// `DiveFormValues` (a known, closed set of fields/types) rather than the
// still-generic `TFieldValues`, so callers get real type safety on both the
// field name and the value they pass in; only the final `form.setValue` call
// needs a narrow cast, since react-hook-form can't verify a `DiveFormValues`
// key/value pair against a still-unresolved `TFieldValues` type parameter.
function setDiveFormValue<
TFieldValues extends DiveFormValues,
TName extends keyof DiveFormValues & string,
>(
form: UseFormReturn<TFieldValues>,
name: TName,
value: DiveFormValues[TName],
) {
form.setValue(
name as unknown as Path<TFieldValues>,
value as unknown as FieldPathValue<TFieldValues, Path<TFieldValues>>,
{ shouldValidate: true, shouldDirty: true },
);
}
// The mixtures currently on the form. Same cast reasoning as `setDiveFormValue`
// above: the field name is known against `DiveFormValues` but not against a
// still-generic `TFieldValues`.
function getDiveFormMixtures<TFieldValues extends DiveFormValues>(
form: UseFormReturn<TFieldValues>,
): DiveMixtureInput[] {
const mixtures = form.getValues(
"mixtures" as unknown as Path<TFieldValues>,
) as unknown as DiveMixtureInput[] | undefined;
return mixtures ?? [];
}
// Whether a form field is still empty, and so free for a fill-only import to
// write. `0` is a recorded reading and not an absence - a freedive to the
// surface logs `max_depth` 0 - so this is deliberately not falsiness.
function isDiveFormFieldEmpty<
TFieldValues extends DiveFormValues,
TName extends keyof DiveFormValues & string,
>(form: UseFormReturn<TFieldValues>, name: TName): boolean {
const value = form.getValues(name as unknown as Path<TFieldValues>);
if (value == null) return true;
if (typeof value === "string") return value.trim() === "";
// A number input the diver cleared reads back as `NaN` rather than as
// `undefined`, which is the one non-obvious empty state on this form.
if (typeof value === "number") return Number.isNaN(value);
if (Array.isArray(value)) return value.length === 0;
return false;
}
// The dive's members a file states beyond its readings, each under the same name
// on `ParsedDive` and on the form.
const PARSED_DIVE_MEMBERS = [
"notes",
"visibility",
"weight",
"water_type",
"altitude",
"type",
"rating",
"air_temperature",
"current",
"waves",
"weather",
"entry_type",
"boat_name",
"tags",
] as const satisfies readonly (keyof ParsedDive & keyof DiveFormValues)[];
/**
* How a parsed file is written onto the form.
*
* `"prefill"` is the first file of a recording: it writes every field the file
* carries, which is what makes importing a dive computer's export worth doing.
*
* `"fill-only"` is every later file — one the API reported as a second export of
* a recording the dive already has, or one picked while another is already
* pending or stored — and it writes only fields the form has left empty. That is
* how "a second file of one recording fills, never overwrites" reaches the
* dive's own fields - its figures, its notes and the rest of what a file states:
* no attach path on the server writes them, so if the rule did not hold here it
* would not hold anywhere. A diver who has just corrected a depth does not
* lose the correction to the same computer's second spelling of the same dive.
* The one exception is a figure an earlier file derived - see `InferredFigures`.
*/
export type ParsedDiveApplyMode = "prefill" | "fill-only";
/**
* The figures on the form a file derived rather than read, this form session
* only: the value it wrote and the computer it came off. Under `"fill-only"` a
* later file that *states* one of these, off the same computer, replaces it -
* the one exception to fills-never-overwrites. A form loaded from a saved dive
* starts with none, since nothing stored says how its figures were made.
*
* Mutated by `applyParsedDiveToForm`; hold one per form, e.g. in a ref.
*/
export type InferredFigures = Partial<
Record<
ParsedDiveInferredField,
{ value: unknown; device: RecordingDevice | null | undefined }
>
>;
export function applyParsedDiveToForm<TFieldValues extends DiveFormValues>(
form: UseFormReturn<TFieldValues>,
parsed: ParsedDive,
// Replaces the `mixtures` field array wholesale. Must come from the *same*
// `useFieldArray({ name: "mixtures" })` instance `MixtureFields` renders
// with (passed down from the page - see `DiveFileImportProps.replaceMixtures`)
// rather than a separate one created here: react-hook-form doesn't reliably
// keep multiple separate `useFieldArray` instances on the same `control`/
// `name` in sync (e.g. `replace()` on one instance doesn't shrink another
// instance's `fields` when the new array is shorter - see DECISIONS.md),
// and plain `form.setValue("mixtures", ...)` has the same problem.
replaceMixtures: (mixtures: DiveMixtureInput[]) => void,
mode: ParsedDiveApplyMode = "prefill",
inferredFigures: InferredFigures = {},
// Told of every write before it lands, with the value it replaces - what the dive
// form marks the fields an import filled in by.
noteWrite?: (path: string, before: unknown, after: unknown) => void,
): MixtureImportNotes {
const setValue = <TName extends keyof DiveFormValues & string>(
name: TName,
value: DiveFormValues[TName],
) => {
noteWrite?.(
name,
form.getValues(name as unknown as Path<TFieldValues>),
value,
);
setDiveFormValue(form, name, value);
};
const replace = (mixtures: DiveMixtureInput[]) => {
noteWrite?.("mixtures", getDiveFormMixtures(form), mixtures);
replaceMixtures(mixtures);
};
// One gate for every scalar field below, so "fill-only" cannot be honoured by
// some of them and forgotten by one. The dive's water type is written only as
// the file states the dive's own: a file's salinity is a setting of the device,
// never a kind of water, and the recording keeps it on attach.
const writes = <TName extends keyof DiveFormValues & string>(name: TName) =>
mode === "prefill" || isDiveFormFieldEmpty(form, name);
const inferred = new Set(parsed.inferred ?? []);
// A figure also passes where an earlier file of this computer derived what the
// form holds and this one states it. "What the form holds" is checked against
// the value written, so a figure the diver has since changed is theirs.
const writesFigure = (name: ParsedDiveInferredField) => {
if (writes(name)) return true;
const mark = inferredFigures[name];
return (
mark != null &&
!inferred.has(name) &&
Object.is(
form.getValues(name as unknown as Path<TFieldValues>),
mark.value,
) &&
sameDevice(mark.device, parsed.device)
);
};
const setFigure = <TName extends ParsedDiveInferredField>(
name: TName,
value: DiveFormValues[TName],
) => {
setValue(name, value);
if (inferred.has(name)) {
inferredFigures[name] = { value, device: parsed.device };
} else {
delete inferredFigures[name];
}
};
if (parsed.dive_number != null && writes("dive_number")) {
setValue("dive_number", parsed.dive_number);
}
// A file that states only a day, applied to a dive that has only a day, keeps
// it a day: the midnight `normalizeParsedStartTime` supplies for a new dive
// would end the state with a time neither of them recorded.
const keepsDateOnly =
isDateOnlyStartTime(parsed.start_time) &&
isDateOnlyStartTime(
form.getValues("start_time" as unknown as Path<TFieldValues>) as
string | undefined,
);
const normalizedStartTime = keepsDateOnly
? parsed.start_time
: parsed.start_time
? normalizeParsedStartTime(parsed.start_time)
: undefined;
if (normalizedStartTime && writes("start_time")) {
setValue("start_time", normalizedStartTime);
}
if (parsed.submerged_time != null && writesFigure("submerged_time")) {
setFigure("submerged_time", formatDurationForForm(parsed.submerged_time));
}
if (parsed.max_depth != null && writesFigure("max_depth")) {
setFigure("max_depth", parsed.max_depth);
}
if (parsed.avg_depth != null && writesFigure("avg_depth")) {
setFigure("avg_depth", parsed.avg_depth);
}
if (parsed.bottom_temperature != null && writes("bottom_temperature")) {
setValue("bottom_temperature", parsed.bottom_temperature);
}
// An empty string or list is a file stating nothing, never one clearing the form.
for (const name of PARSED_DIVE_MEMBERS) {
const value = parsed[name];
if (value != null && isNonEmptyFieldValue(value) && writes(name)) {
setValue(name, value);
}
}
if (parsed.mixtures.length === 0) {
return NO_MIXTURE_NOTES;
}
const existing = getDiveFormMixtures(form);
if (mode === "fill-only") {
// A later file may fill a cylinder the form already has; it may not
// reshape the list, and it fills only the rows the API would pair it with
// at the attach (`fillMixtures`), since what the form writes is saved
// first. A form with no cylinders at all takes the file's, which fills
// rather than overwrites by definition - without the file's labels, which
// the attach writes.
if (existing.length === 0) {
replace(
parsed.mixtures.map((mixture) => ({
...mergeMixture(mixture).value,
gas_number: undefined,
})),
);
} else {
const filled = fillMixtures(parsed.mixtures, existing);
if (filled) replace(filled);
}
return NO_MIXTURE_NOTES;
}
const merged = parsed.mixtures.map((mixture, index) =>
mergeMixture(
mixture,
existingMixtureFor(existing, parsed.mixtures.length, index),
),
);
replace(merged.map((cylinder) => cylinder.value));
return mixtureImportNotes(parsed.mixtures, merged, existing);
}
export interface DiveFileImportProps<TFieldValues extends DiveFormValues> {
form: UseFormReturn<TFieldValues>;
// Must come from the same `useFieldArray` instance passed to `DiveFormFields`
// as `mixtureFieldArray` (i.e. `replace` destructured from it) - see the
// `applyParsedDiveToForm` doc comment above for why a separate instance
// created here wouldn't work.
replaceMixtures: (mixtures: DiveMixtureInput[]) => void;
// Called only after a *successful* parse, with a file the page should hold
// until the dive has been saved - see `divesAPI.attachRecordingFile`. A failed
// parse applied nothing to the form, so there is nothing to attach and this
// isn't called.
//
// More than one file can be waiting: a diver logging one dive off two
// computers, or the same computer's JSON beside its FIT, picks each in turn
// and every one of them is attached on save.
onFileAdded?: (pending: PendingDiveFile) => void;
// Raised the moment a parsed file has been written onto the form, before the
// toast. The dive form uses it to put every field the file filled in back on
// screen when the diver has it hidden - an imported cylinder volume the parser
// guessed must not sit behind an unchecked box. `applyParsedDiveToForm` itself is
// deliberately visibility-blind: it sets whatever the file carries, which is the
// owner's import rule for free.
onValuesApplied?: () => void;
// Told of each value the import writes, before it lands - see
// `applyParsedDiveToForm`'s `noteWrite`.
onWrite?: (path: string, before: unknown, after: unknown) => void;
// The files this dive already holds, on the edit form. Empty on the create
// form, where nothing is stored until the dive exists.
recordings?: Recording[];
// Files parsed on this form and not yet attached, held by the page.
pending?: PendingDiveFile[];
onRemovePending?: (id: string) => void;
// Stored files struck off the list, and the two controls over that. Edit form
// only, and deferred: the deletions go out with the save, beside the attaches,
// so that Cancel leaves the dive's files exactly as it found them.
removedStored?: string[];
onRemoveStored?: (fileUuid: string) => void;
onRestoreStored?: (fileUuid: string) => void;
// The dive being edited, so a match against *this* dive can be told from a
// match against another one. Absent on the create form, where every match is
// another dive by definition.
diveUuid?: string;
// Where the form goes back to, which the dive a file is attached to instead
// leads back to as well, as a saved dive does.
returnTo?: string;
// The section's content with no card around it: what the Recordings card's
// edit dialog holds.
bare?: boolean;
}
export function DiveFileImport<TFieldValues extends DiveFormValues>({
form,
replaceMixtures,
onFileAdded,
onValuesApplied,
onWrite,
recordings = [],
pending = [],
onRemovePending,
removedStored,
onRemoveStored,
onRestoreStored,
diveUuid,
returnTo,
bare = false,
}: DiveFileImportProps<TFieldValues>) {
const { toast } = useToast();
const router = useRouter();
const [isParsingFile, setIsParsingFile] = useState(false);
// What the import had to guess, shown next to the file list rather than in the
// toast: the toast is gone in seconds, and this is exactly the thing the diver
// has to still be able to see while fixing it.
//
// The rendered sentence, not the structured notes - it is a statement about the
// file and never changes after import, so there is nothing to re-derive. See
// `describeMixtureImport`.
const [importNote, setImportNote] = useState<string | null>(null);
// A parsed file whose bytes look like they belong to a dive that already
// exists, waiting for the diver to say which. **This path never attaches by
// itself**, whatever the match says: a wrong match on a form is a dive the
// diver did not ask for with nothing on screen to refuse it. Logbook import
// is the opposite case and does attach automatically, because its preview is
// already the confirmation step and its test is far stricter than this one.
const [offer, setOffer] = useState<MatchOffer | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const inferredFigures = useRef<InferredFigures>({});
// Whether a newly parsed file is allowed to overwrite what is on the form.
//
// The first file of a dive is the form's best information and writes
// everything it carries. Every later one - a second computer, or this
// computer's other export - fills blanks only (and figures an earlier file of
// the same computer derived, `InferredFigures`), which is how first-file-wins
// reaches the dive's own fields: no server-side attach writes them, so if the
// rule does not hold here it holds nowhere. `DECISIONS.md`, *"A second file of
// one recording fills the form, and never overwrites it"*, has the whole
// argument.
const hasFileAlready = pending.length > 0 || recordings.length > 0;
// Applies a parsed file to the form and hands it to the page to attach on
// save. Shared by the ordinary path and by "Log as a new dive", which is the
// same thing after the diver has refused a match.
//
// `alreadyHasFile` is passed rather than read off `hasFileAlready`, because
// within one pick this is called several times before React has re-rendered
// with the files it added: the prop still says "none" while the second file
// of the batch is being applied, and first-file-wins would collapse into
// last-file-wins.
const acceptParsedFile = (
file: File,
parsed: ParsedDive,
alreadyHasFile: boolean,
) => {
const sameRecording = (parsed.matches ?? []).some(
(match) => match.same_recording && match.dive_uuid === diveUuid,
);
const notes = applyParsedDiveToForm(
form,
parsed,
replaceMixtures,
alreadyHasFile || sameRecording ? "fill-only" : "prefill",
inferredFigures.current,
onWrite,
);
onValuesApplied?.();
onFileAdded?.({
id: pendingFileId(),
file,
token: parsed.file_token,
deviceLabel: recordingDeviceLabel(parsed.device),
entry:
parsed.entry_latitude != null && parsed.entry_longitude != null
? {
latitude: parsed.entry_latitude,
longitude: parsed.entry_longitude,
}
: null,
});
// Kept rather than replaced. Only a prefill has anything to report, so
// every file after the first answers `null` - and assigning that would wipe
// the first file's note off the screen mid-batch. The pick clears it once,
// in `handleFilesSelected`.
const note = describeMixtureImport(notes);
if (note) setImportNote(note);
};
// One toast for the pick rather than one per file: a diver emptying a
// computer's card picks several at once, and a stack of identical toasts is
// the same sentence four times over.
const announceImported = (count: number) => {
if (count === 0) return;
toast({
title: count === 1 ? "Dive file parsed" : `${count} dive files parsed`,
description:
count === 1
? "Form fields have been filled in from the uploaded file. Please review before saving."
: "Form fields have been filled in from the uploaded files. Please review before saving.",
});
};
// Parses the picked files one at a time and writes each onto the form, in
// pick order.
//
// **Serially, and never in parallel.** The mode each file is applied in
// depends on what the ones before it left behind, so a `Promise.all` here
// would make first-file-wins depend on which response came back first.
//
// One bad file doesn't end the batch: an oversized or unparseable file says
// so and the rest carry on, since the diver picked them together and has no
// way to re-pick "the other three". A *match* does pause it - that is a
// question only they can answer - and the remainder rides along on the offer
// until they have.
const importFiles = async (
files: File[],
seed: { hasFile: boolean; accepted: number },
) => {
let hasFile = seed.hasFile;
let accepted = seed.accepted;
setIsParsingFile(true);
try {
for (const [index, file] of files.entries()) {
// Checked here as well as by the API so a diver on a slow connection
// isn't made to upload an oversized file before being told no. The API
// re-checks regardless, and its check is the one that counts.
if (file.size > MAX_DIVE_FILE_SIZE) {
toast({
title: "File too large",
description: `${file.name} is larger than 5 MB, so it wasn't read.`,
variant: "destructive",
});
continue;
}
let parsed: ParsedDive;
try {
parsed = await divesAPI.parseDiveFile(file);
} catch (error) {
console.error("Failed to parse dive file:", error);
toast({
title: "Error",
description: getApiErrorMessage(
error,
`Failed to parse ${file.name}. Please check the file and try again.`,
),
variant: "destructive",
});
continue;
}
// A match on a dive the diver is not looking at is the one case that
// stops and asks. A match on *this* dive is not a question - the file
// belongs here, which is what the form is already doing - it only
// decides that the write fills rather than overwrites, above.
const elsewhere = (parsed.matches ?? []).filter(
(match) => match.dive_uuid !== diveUuid,
);
if (elsewhere.length > 0) {
setOffer({
file,
parsed,
match: elsewhere[0],
rest: files.slice(index + 1),
accepted,
});
return;
}
acceptParsedFile(file, parsed, hasFile);
hasFile = true;
accepted += 1;
}
announceImported(accepted);
} finally {
setIsParsingFile(false);
}
};
const handleFilesSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(e.target.files ?? []);
// Cleared before the first parse rather than after the last. The input is
// what has to fire `change` again if the diver re-picks the same file, and
// holding the old selection across an upload that takes seconds is how that
// gets missed.
e.target.value = "";
if (files.length === 0) return;
setImportNote(null);
await importFiles(files, {
hasFile: hasFileAlready,
accepted: 0,
});
};
// "Attach there": the file joins the dive it matched, right now, and the diver
// goes to look at it. Nothing on this form is saved - the dive they were
// filling in is the one this file turned out not to be.
const attachToMatch = async () => {
if (!offer) return;
try {
setIsAttaching(true);
await divesAPI.attachRecordingFile(
offer.match.dive_uuid,
offer.file,
offer.parsed.file_token,
);
toast({
title: "File attached",
description: `Added to dive #${offer.match.dive_number}.`,
});
setOffer(null);
router.push(
withReturnTo(`/dives/${offer.match.dive_uuid}`, returnTo ?? null),
);
} catch (error) {
console.error("Failed to attach the dive file:", error);
toast({
title: "Error",
description: getApiErrorMessage(
error,
"Failed to attach the file to that dive. Please try again.",
),
variant: "destructive",
});
} finally {
setIsAttaching(false);
}
};
const uploadButton = (
<>
<input
ref={fileInputRef}
type="file"
accept={DIVE_COMPUTER_FILE_ACCEPT}
// A dive off two computers, or one computer's JSON beside its FIT,
// was always two trips through this picker for no reason: the form
// already holds a list and the server already decides per file
// which recording it joins.
multiple
className="hidden"
onChange={handleFilesSelected}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isParsingFile}
>
{isParsingFile ? (
<>
<ButtonSpinner className="mr-2" />
Parsing...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Upload dive files
</>
)}
</Button>
</>
);
const content = (
<>
{offer && (
<ConfirmDialog
open
onOpenChange={(open) => !open && setOffer(null)}
title="This file may already have a dive"
description={describeMatch(offer.match)}
confirmText="Attach there"
variant="default"
isLoading={isAttaching}
secondaryAction={{
label: "Log as a new dive",
onClick: () => {
const { file, parsed, rest, accepted } = offer;
setOffer(null);
acceptParsedFile(file, parsed, hasFileAlready);
// Picks the batch back up where the question interrupted it, and
// does the announcing even when nothing is left: `rest` empty is
// the ordinary single-file case, and the toast belongs to the
// pick rather than to the loop.
void importFiles(rest, { hasFile: true, accepted: accepted + 1 });
},
}}
onConfirm={attachToMatch}
/>
)}
{/* Rendered unconditionally and `sr-only` until there is something to say: a
`role="status"` region that mounts together with its text is typically not
announced at all, since screen readers register it on insertion and read
*subsequent* changes. The text is computed once at import and never changes
afterwards, so this announces exactly once - see `describeMixtureImport`. */}
<p
role="status"
className={importNote ? "text-sm text-warning" : "sr-only"}
>
{importNote}
</p>
<DiveRecordingFiles
recordings={recordings}
pending={pending}
onRemovePending={(id) => onRemovePending?.(id)}
removedStored={removedStored}
onRemoveStored={onRemoveStored}
onRestoreStored={onRestoreStored}
/>
</>
);
if (bare) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
{uploadButton}
<DiveFileImportInfo />
</div>
{content}
</div>
);
}
return (
<DiveFormSection
title="Import"
titleAdornment={<DiveFileImportInfo />}
action={uploadButton}
empty={
!importNote &&
pending.length === 0 &&
diveFileRows(recordings).length === 0
}
>
{content}
</DiveFormSection>
);
}
// What the import does, beside its section's title.
//
// A popover rather than a hover hint: a paragraph is more than a hint carries, and a
// tap has to open it on a phone.
function DiveFileImportInfo() {
return (
<Popover>
<IconTooltip label="About importing a file">
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground"
/>
}
>
<Info className="h-4 w-4" />
</PopoverTrigger>
</IconTooltip>
<PopoverContent align="start" className="text-sm">
Upload this dive's file, in any format logbook import reads, to
fill in the fields below. Pick as many as you like at once: one per
computer that recorded this dive, or one computer's second export
alongside its first. A file holding several dives goes through logbook
import instead.
</PopoverContent>
</Popover>
);
}
// A parsed file the API matched against an existing dive, and the match it is
// being offered against. Only the nearest is offered: the list is ordered by
// start, and a second-best candidate is a question nobody can answer better
// than the first.
interface MatchOffer {
file: File;
parsed: ParsedDive;
match: ParsedDiveMatch;
/**
* The files picked after this one, waiting on the answer. Held here rather
* than in state of their own so that dismissing the dialog drops them with
* the question - refusing to answer is not an instruction to carry on.
*/
rest: File[];
/** How many of this pick's files reached the form before the question. */
accepted: number;
}
// What the match dialog says. Names the dive and the device, because those are
// the two things that make the offer checkable - "a dive nearby" is not
// something a diver can agree or disagree with.
function describeMatch(match: ParsedDiveMatch): string {
const device = recordingDeviceLabel(match.device);
const when = match.started_at
? formatDiveStartTime(match.started_at)
: "an unrecorded time";
return match.same_recording
? `Dive #${match.dive_number} already has a recording from ${device ?? "the same computer"}, starting ${when}. Attaching adds this file to that recording and fills in whatever it left blank — nothing already recorded is overwritten.`
: `Dive #${match.dive_number} started around the same time (${when}${device ? `, recorded by ${device}` : ""}). Attaching adds this file to that dive as a second recording.`;
}
// `crypto.randomUUID` is available in every browser this app supports and in
// jsdom, but not over plain HTTP on a LAN address - a self-hoster's first
// look at the app from another machine. The counter is the fallback, and is
// only ever a key within one form's lifetime.
let pendingFileCounter = 0;
function pendingFileId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
pendingFileCounter += 1;
return `pending-${pendingFileCounter}`;
}