src/lib/logbook-import.ts
Rama main · réplica de lectura del repositorio original.
import {
MAX_IMPORT_ARCHIVE_SIZE,
MAX_IMPORT_DOCUMENT_SIZE,
MAX_IMPORT_FILES,
type ImportCollectionReport,
type ImportDiveOutcome,
type ImportFileReport,
type ImportNoteGroup,
type LogbookImportDive,
type LogbookImportRead,
type LogbookImportSummary,
} from "@/lib/api/logbook-import";
import {
formatDiveDateTime,
formatUtcOffset,
isDateOnlyStartTime,
parseUtcOffsetMinutes,
} from "@/lib/date-time";
import { countNoun } from "@/lib/format";
import { labelOrFallback } from "@/lib/api/labels";
// What each envelope collection is called on screen. The API sends the wire name
// ("gear_service_schedules") and the diver reads the app's own word for the thing
// ("Service schedules"), which is not derivable by title-casing: "sites" is "Dive
// sites" here and "gear_sets" is "Gear sets", not "Gear Sets".
//
// Keyed by the wire name in the envelope's own order, which is the order the API
// returns the collections in and the order they render in.
const COLLECTION_LABELS: Record<string, string> = {
dives: "Dives",
trips: "Trips",
courses: "Courses",
sites: "Dive sites",
species: "Marine life",
gear: "Gear",
gear_sets: "Gear sets",
gear_service_schedules: "Service schedules",
gear_service_records: "Service records",
certifications: "Certifications",
contacts: "Contacts",
people: "People",
};
/**
* A collection's display name. A newer API can grow a collection this build has
* no label for, which falls back as `labelOrFallback` says; counts are the point
* of the row and they are correct whatever the heading says.
*/
export function collectionLabel(collection: string): string {
return labelOrFallback(COLLECTION_LABELS, collection);
}
/** The three disjoint counts, summed across collection rows. */
export interface ImportTotals {
created: number;
linked: number;
skipped: number;
}
/**
* Adds the collection rows on screen up, so the total is the sum of what is
* shown above it.
*/
export function importTotals(
rows: readonly ImportCollectionReport[],
): ImportTotals {
return rows.reduce<ImportTotals>(
(totals, row) => ({
created: totals.created + row.created,
linked: totals.linked + row.linked,
skipped: totals.skipped + row.skipped,
}),
{ created: 0, linked: 0, skipped: 0 },
);
}
/** Whether a collection row has anything in it at all. */
export function collectionRowIsEmpty(row: ImportCollectionReport): boolean {
return row.created === 0 && row.linked === 0 && row.skipped === 0;
}
/**
* The collection rows the counts table shows.
*
* Empty rows are left out, and so is the dives row wherever the import has dive
* rows: `collections` counts a document's records by what the planner does to
* each, so a computer's second export of a dive, or a file adding to a dive the
* diver has, counts there as a skipped dive while its dive row says "new" or
* "modified". The dive rows are the report on dives; the counts are the report
* on everything else.
*/
export function reviewCollectionRows(
record: Pick<LogbookImportRead, "collections" | "dives">,
): ImportCollectionReport[] {
return record.collections.filter(
(row) =>
!collectionRowIsEmpty(row) &&
!(row.collection === "dives" && record.dives.length > 0),
);
}
/**
* The sentence that says the note list is not all of them, or `null` when it is.
*
* The API caps the groups and ships the overflow as a count precisely so a
* client can say this; without it a capped list reads as though it were all of
* them.
*/
export function truncatedNotesSentence(notesTruncated: number): string | null {
if (notesTruncated <= 0) return null;
return `${countNoun(notesTruncated, "further note is", "further notes are")} not shown.`;
}
/**
* What a bare document cannot do, said once, or `null` when it does not apply.
*
* A document references its stored files by digest and carries none of their
* bytes, so every referenced file comes back `not_contained`. That is a smaller
* restore rather than a failure, and the diver's move - re-import the archive -
* is worth naming at the moment they can see the number.
*/
export function fileRestoreHint(
files: ImportFileReport,
archive: boolean,
): string | null {
if (archive || files.not_contained === 0) return null;
const n = files.not_contained;
return `${n === 1 ? "This file is" : `These ${n} files are`} named by the document but not carried in it — import the full archive (.zip) to restore the bytes as well.`;
}
// What each note's kind is called on screen. Deliberately not a
// `Record<NoteKind, string>`: there is no such union to key on, because the API
// sends this field as an opaque string.
const NOTE_KIND_LABELS: Record<string, string> = {
absent: "Not recorded",
inferred: "Worked out",
resolved: "Resolved",
dropped: "Dropped",
};
/**
* A note's badge text, or `null` for a note with no kind - an import decision
* such as a linked record, which needs no badge to read right.
*
* The API sends `kind` as an **opaque string on purpose** and says so in the
* field's own description: the file readers' set gained a fourth kind once, and
* the pin that decides which set a build sees moves with no change here. So a
* fifth falls back as `labelOrFallback` says rather than as a blank badge.
*/
export function noteKindLabel(kind: string | null): string | null {
return kind === null ? null : labelOrFallback(NOTE_KIND_LABELS, kind);
}
/** How loudly a note should read. */
export type NoteKindTone = "warning" | "neutral" | "muted";
/**
* Whether a note cost the diver something, merely explains, or is an absence.
*
* Only `dropped` is a loss: a file recorded something, or the document named a
* record, and the diver ends up without it. `absent` is what a file never
* recorded at all, which is a property of the file rather than of the import,
* so it is quietest. `inferred`, `resolved` and a note with no kind - a linked
* record, a recording attached to a dive it matched, a file whose
* bytes a bare document simply does not carry - are the import working as
* designed, and colouring them as problems would teach a diver to distrust a
* correct result.
*
* **An unfamiliar kind reads as information, never as a warning**: a build that
* predates a kind should render it plainly rather than invent alarm about a
* sentence it cannot interpret. This branch is reachable without anyone
* touching this repo - the API's reader pin moves on its own.
*/
export function noteKindTone(kind: string | null): NoteKindTone {
if (kind === "dropped") return "warning";
if (kind === "absent") return "muted";
return "neutral";
}
/**
* How often a group came up and where, or `null` for a note that came up once
* and names no place.
*
* The API sends at most three `wheres` beside a complete `count`, so the count
* is the honest figure and the paths are a pointer into the files rather than
* the report. "and N more" is the difference between them, which is why it is
* derived here rather than sent: a group whose count equals what it listed says
* nothing extra. The import's own notes name no place, so theirs is a count of
* times.
*/
export function noteGroupPlacesSentence(group: ImportNoteGroup): string | null {
if (group.wheres.length === 0) {
return group.count > 1 ? `${group.count} times` : null;
}
const places = countNoun(group.count, "place");
const more = group.count - group.wheres.length;
const listed =
more > 0
? `${group.wheres.join(", ")} and ${more} more`
: group.wheres.join(", ");
return `${places} — ${listed}`;
}
/**
* The sentence under a dive's notes that says the API cut the row's list, or
* `null` when it did not. What the row leaves out is in the report's notes.
*/
export function truncatedDiveNotesSentence(truncated: number): string | null {
if (truncated <= 0) return null;
return `${countNoun(truncated, "further note about this dive is", "further notes about this dive are")} listed with the import's notes below.`;
}
const OUTCOME_LABELS: Record<ImportDiveOutcome, string> = {
new: "New",
modified: "Modified",
unchanged: "Unchanged",
skipped: "Skipped",
};
/** An outcome in the diver's words, tolerating one this build does not know. */
export function importOutcomeLabel(outcome: ImportDiveOutcome): string {
return labelOrFallback(OUTCOME_LABELS, outcome);
}
/**
* A dive's start as the file gives it: its wall clock, then the offset that clock
* was on where it records one. A naive start stops after the clock, and a bare
* date after the date - no midnight, and no zone nothing recorded.
*/
export function importStartText(startTime: string | null): string {
if (!startTime) return "—";
const when = formatDiveDateTime(startTime);
if (isDateOnlyStartTime(startTime)) return when;
const offset = parseUtcOffsetMinutes(startTime);
return offset === null ? when : `${when} ${formatUtcOffset(offset)}`;
}
/** A row the review draws a card for: the rest are named on the log page. */
export function importRowHasCard(row: LogbookImportDive): boolean {
return (
(row.outcome === "new" || row.outcome === "modified") && row.dive !== null
);
}
const OUTCOME_ORDER: readonly ImportDiveOutcome[] = [
"new",
"modified",
"unchanged",
"skipped",
];
function outcomeCounts(
rows: readonly Pick<LogbookImportDive, "outcome">[],
): Record<ImportDiveOutcome, number> {
const counts = { new: 0, modified: 0, unchanged: 0, skipped: 0 };
for (const row of rows) counts[row.outcome] += 1;
return counts;
}
export interface ImportSummaryTerm {
outcome: ImportDiveOutcome;
text: string;
}
/**
* The summary line's terms, "12 new dives", "2 modified", "5 unchanged", "1
* skipped", the zero ones left out. The first says what is being counted, and
* the rest lean on it.
*/
export function importSummaryTerms(
rows: readonly Pick<LogbookImportDive, "outcome">[],
): ImportSummaryTerm[] {
const counts = outcomeCounts(rows);
return OUTCOME_ORDER.filter((outcome) => counts[outcome] > 0).map(
(outcome, index) => ({
outcome,
text:
index === 0
? `${counts[outcome]} ${outcome} ${counts[outcome] === 1 ? "dive" : "dives"}`
: `${counts[outcome]} ${outcome}`,
}),
);
}
/** A history row's counts in a sentence: "12 new, 2 modified". */
export function importCountsSentence(
counts: LogbookImportSummary["counts"],
): string {
const terms = OUTCOME_ORDER.filter((outcome) => counts[outcome]).map(
(outcome) => `${counts[outcome]} ${outcome}`,
);
return terms.length > 0 ? terms.join(", ") : "No dives";
}
/**
* The import button's label: the dives the review draws a card for, or "Import"
* where there are none. **It never says an import would write nothing**: a
* check-in detail or a tag is written without a dive row saying so, so the button
* stays offered on every pending import.
*/
export function importButtonLabel(
dives: readonly Pick<LogbookImportDive, "outcome">[],
): string {
const written = dives.filter(
(row) => row.outcome === "new" || row.outcome === "modified",
).length;
return written > 0 ? `Import ${countNoun(written, "dive")}` : "Import";
}
/**
* Whether a picked path is packaging rather than a file: a dot-file, anything
* under a dot-folder, or anything under a `__MACOSX/` shadow tree. Dropped
* silently, as the API drops them from a zip.
*/
export function isHiddenImportPath(path: string): boolean {
return path
.split("/")
.some((segment) => segment.startsWith(".") || segment === "__MACOSX");
}
/**
* Whether a file is a zip, by its name or type - used only to choose the size
* bound the API will hold it to. The API decides by the bytes.
*/
export function isZipFile(file: Pick<File, "name" | "type">): boolean {
return /\.zip$/i.test(file.name) || file.type === "application/zip";
}
function megabytes(bytes: number): string {
return `${Math.round(bytes / (1024 * 1024))} MB`;
}
/**
* Why one picked file certainly cannot be read, or `null`. A file this refuses
* is shown refused in its own row and left out of the request, as the API would
* make it a refused row; the rest still reads.
*/
export function importFileRefusal(
file: Pick<File, "name" | "type" | "size">,
): string | null {
if (isZipFile(file)) {
return file.size > MAX_IMPORT_ARCHIVE_SIZE
? `A zip may be up to ${megabytes(MAX_IMPORT_ARCHIVE_SIZE)}.`
: null;
}
return file.size > MAX_IMPORT_DOCUMENT_SIZE
? `A file that is not a zip may be up to ${megabytes(MAX_IMPORT_DOCUMENT_SIZE)}. Import the full-export archive if you are restoring a whole account with its files.`
: null;
}
/**
* Why the files to be sent cannot go as one import, or `null` - the two bounds
* of the whole request the API certainly holds it to.
*/
export function importSelectionRefusal(
files: readonly Pick<File, "size">[],
): string | null {
if (files.length > MAX_IMPORT_FILES) {
return `One import may carry up to ${MAX_IMPORT_FILES} files. Zip them and import the zip instead.`;
}
const total = files.reduce((sum, file) => sum + file.size, 0);
return total > MAX_IMPORT_ARCHIVE_SIZE
? `One import may carry up to ${megabytes(MAX_IMPORT_ARCHIVE_SIZE)}. Import these files in parts.`
: null;
}