From 0e8a8a418a3486013046d9195b57491404b4b145 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Thu, 24 Sep 2026 19:25:25 +0300 Subject: [PATCH 1/2] feat(import): the archive's portrait in the import preview, and what an import applies Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- src/app/privacy/page.tsx | 23 +-- .../settings/data-export-card.render.test.tsx | 6 +- src/components/settings/data-export-card.tsx | 2 +- .../settings/data-import-card.render.test.tsx | 120 ++++++++++++++- src/components/settings/data-import-card.tsx | 30 ++-- .../settings/import-check-in-details.tsx | 145 ++++++++++++++++-- src/lib/api/logbook-import.test.ts | 33 +++- src/lib/api/logbook-import.ts | 43 +++++- src/lib/import-check-in.test.ts | 31 +++- src/lib/import-check-in.ts | 19 ++- src/lib/logbook-import.test.ts | 2 + src/lib/logbook-import.ts | 16 +- 13 files changed, 416 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 0bb82ccd..70cf55d3 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ one. converted file also gets a report of what the conversion could not carry. Records you already have are matched rather than duplicated, and a dive you deleted returns under its own identity. The archive additionally restores the dive-computer files and c-card scans, which the bare document - names by digest but does not carry. + names by digest but does not carry. The check-in details a logbook carries, and an archive's + portrait, are shown beside yours in the preview, and only what you choose there is saved. - **Passwordless sign-in** — email magic links or Google; no passwords stored, ever. - **Dark mode & responsive** — works on the boat, in the dive shop, and on your desk. diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index fa4eb754..e476c814 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -241,14 +241,16 @@ export default async function PrivacyPage() {

- Both pictures are kept as the file you picked, so that you can - change how either is cropped later, with the location and camera - details a phone photo carries removed by this server before the - file is stored; what the app shows is drawn from that file at the - crop you chose. Your portrait is shown to you where you edit it - and on your check-in page, and never where your profile picture - appears. The full archive export carries both files, and both are - deleted with your account. + Both pictures are kept as the file you picked — or, for the + portrait, the one a full archive carries, if you take it in the + import preview — so that you can change how either is + cropped later, with the location and camera details a phone photo + carries removed by this server before the file is stored; what the + app shows is drawn from that file at the crop you chose. Your + portrait is shown to you where you edit it and on your check-in + page, and never where your profile picture appears. The full + archive export carries both files, and both are deleted with your + account.

One thing arrives without you typing it. If you create your @@ -389,8 +391,9 @@ export default async function PrivacyPage() {

  • GPS positions inside a photo you pick as your profile picture or - portrait, which travel with the upload and are removed by this - server before the photo is stored + portrait, or inside the portrait a full archive carries when you + take it on import; they travel with the upload and are removed + by this server before the photo is stored
  • diff --git a/src/components/settings/data-export-card.render.test.tsx b/src/components/settings/data-export-card.render.test.tsx index ab2e93b8..239ffcab 100644 --- a/src/components/settings/data-export-card.render.test.tsx +++ b/src/components/settings/data-export-card.render.test.tsx @@ -90,8 +90,10 @@ describe("DataExportCard", () => { expect(warning).toMatch( /card scans and the portrait are personal documents/, ); - // In the archive, and - until an import can restore it - not put back by one. - expect(warning).toMatch(/portrait is in it too, though an import does not/); + // In the archive, and offered back by an import rather than restored silently. + expect(warning).toMatch( + /portrait is in it too, and importing the archive offers it back/, + ); }); it.each([ diff --git a/src/components/settings/data-export-card.tsx b/src/components/settings/data-export-card.tsx index 7af27aff..4a364cca 100644 --- a/src/components/settings/data-export-card.tsx +++ b/src/components/settings/data-export-card.tsx @@ -81,7 +81,7 @@ const EXPORT_ROWS: ExportRow[] = [ 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 — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore bytes it actually carries. Your portrait is in it too, though an import does not put that back yet. The card scans and the portrait are personal documents, so treat the file as one.", + "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 — and every certification card image you uploaded. This is the one to keep as a backup — it is also the only download that can put those files back, since the import card below can restore 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.", }, ]; diff --git a/src/components/settings/data-import-card.render.test.tsx b/src/components/settings/data-import-card.render.test.tsx index fcb8debc..b369b467 100644 --- a/src/components/settings/data-import-card.render.test.tsx +++ b/src/components/settings/data-import-card.render.test.tsx @@ -27,7 +27,18 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/contexts/AuthContext", () => ({ - useAuth: () => ({ refreshUser: mocks.refreshUser }), + useAuth: () => ({ user: { name: "Alex" }, refreshUser: mocks.refreshUser }), +})); + +// The account's portrait is fetched through this, the endpoint being owner-only. +// Answering with a URL is what puts its `` on the page. +vi.mock("@/hooks/useAuthedBlobUrl", () => ({ + useAuthedBlobUrl: (fetchBlob: unknown) => ({ + url: fetchBlob ? "blob:portrait" : null, + isLoading: false, + hasError: false, + error: null, + }), })); vi.mock("@/lib/api/logbook-import", async (importOriginal) => ({ @@ -63,6 +74,7 @@ function preview(overrides: Partial = {}): ImportPreview { archive: false, token: "tok-1", check_in_details: [], + portrait: null, ...overrides, }; } @@ -548,3 +560,109 @@ describe("the check-in details in an import preview", () => { expect(mocks.refreshUser).not.toHaveBeenCalled(); }); }); + +describe("the archive's portrait in an import preview", () => { + const sha = "b".repeat(64); + const proposed = "data:image/webp;base64,UklGRg=="; + + const applyButton = () => + screen.getByRole("button", { name: /import this logbook/i }); + + async function previewWith(overrides: Partial) { + mocks.apply.mockResolvedValue(report()); + mocks.preview.mockResolvedValue(preview({ archive: true, ...overrides })); + render(); + await choose(new File(["zip"], "backup.zip", { type: "application/zip" })); + await screen.findByText(/nothing has been written yet/i); + } + + it("shows the account's beside the archive's, the archive's chosen", async () => { + await previewWith({ portrait: { account_sha256: sha, proposed } }); + + const row = screen.getByRole("group", { name: "Portrait" }); + expect(within(row).getByAltText("Portrait of Alex")).toHaveAttribute( + "src", + "blob:portrait", + ); + expect(within(row).getByAltText("Portrait from this file")).toHaveAttribute( + "src", + proposed, + ); + expect( + within(row).getByText("This file's replaces yours when you import."), + ).toBeVisible(); + + await userEvent.click(applyButton()); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1)); + expect(mocks.apply.mock.calls[0][3]).toEqual({ + choice: "take", + account_sha256: sha, + }); + }); + + it("sends keep, with the digest the preview showed, after Keep mine", async () => { + await previewWith({ portrait: { account_sha256: sha, proposed } }); + + const row = screen.getByRole("group", { name: "Portrait" }); + await userEvent.click( + within(row).getByRole("button", { name: "Keep mine" }), + ); + expect( + within(row).getByText("Keeping yours; nothing from this file is saved."), + ).toBeVisible(); + + await userEvent.click(applyButton()); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1)); + // The digest goes on keep too: the API requires the key either way. + expect(mocks.apply.mock.calls[0][3]).toEqual({ + choice: "keep", + account_sha256: sha, + }); + }); + + it("sends a null digest for an account with no portrait", async () => { + await previewWith({ portrait: { account_sha256: null, proposed } }); + + const row = screen.getByRole("group", { name: "Portrait" }); + expect(within(row).getByRole("img", { name: "No portrait" })).toBeVisible(); + await userEvent.click( + within(row).getByRole("button", { name: "Leave unset" }), + ); + + await userEvent.click(applyButton()); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1)); + expect(mocks.apply.mock.calls[0][3]).toEqual({ + choice: "keep", + account_sha256: null, + }); + }); + + it("shows the section with the portrait alone for an archive carrying no fact", async () => { + await previewWith({ portrait: { account_sha256: sha, proposed } }); + + expect(screen.getByText("Check-in details")).toBeVisible(); + expect(screen.getAllByRole("group")).toHaveLength(1); + expect(screen.getByRole("group", { name: "Portrait" })).toBeVisible(); + }); + + it("shows no row, and sends no choice, when nothing is offered", async () => { + await previewWith({ + check_in_details: [{ detail: "phone", account: null, proposed: "+44 2" }], + }); + + expect(screen.getByText("Check-in details")).toBeVisible(); + expect(screen.queryByRole("group", { name: "Portrait" })).toBeNull(); + await userEvent.click(applyButton()); + await waitFor(() => expect(mocks.apply).toHaveBeenCalledTimes(1)); + expect(mocks.apply.mock.calls[0][3]).toBeUndefined(); + }); + + it("says in the dropzone that the portrait and the check-in details are offered", () => { + render(); + expect( + screen.getByText( + /check-in details a logbook carries, and an archive.s portrait, are shown beside yours/, + ), + ).toBeVisible(); + }); +}); diff --git a/src/components/settings/data-import-card.tsx b/src/components/settings/data-import-card.tsx index e0149644..cfd7a501 100644 --- a/src/components/settings/data-import-card.tsx +++ b/src/components/settings/data-import-card.tsx @@ -16,6 +16,7 @@ import { useToast } from "@/components/ui/use-toast"; import { ImportCheckInDetails, useImportCheckIn, + type ImportCheckInChoices, } from "@/components/settings/import-check-in-details"; import { useAuth } from "@/contexts/AuthContext"; import { getApiErrorMessage } from "@/lib/api/error"; @@ -25,7 +26,6 @@ import { LOGBOOK_IMPORT_ACCEPT, MAX_IMPORT_ARCHIVE_SIZE, MAX_IMPORT_DOCUMENT_SIZE, - type ImportCheckInSubmission, type ImportPreview, type ImportReport, } from "@/lib/api/logbook-import"; @@ -290,14 +290,14 @@ function PendingImport({ file: File; preview: ImportPreview; isApplying: boolean; - onApply: (checkIn: ImportCheckInSubmission | undefined) => void; + onApply: (choices: ImportCheckInChoices) => void; onCancel: () => void; }) { - const checkIn = useImportCheckIn(preview.check_in_details); + const checkIn = useImportCheckIn(preview.check_in_details, preview.portrait); const handleApply = async () => { - const submission = await checkIn.collect(); - if (submission !== null) onApply(submission); + const choices = await checkIn.collect(); + if (choices !== null) onApply(choices); }; return ( @@ -421,7 +421,7 @@ export function DataImportCard() { } }; - const handleApply = async (checkIn: ImportCheckInSubmission | undefined) => { + const handleApply = async ({ details, portrait }: ImportCheckInChoices) => { if (!pending) return; try { @@ -429,11 +429,13 @@ export function DataImportCard() { const applied = await logbookImportAPI.apply( pending.file, pending.preview.token, - checkIn, + details, + portrait, ); - // Only when a fact changed: the check-in card on this page seeds from the - // signed-in user, and saving it from a stale copy would send the imported - // facts back as nulls. Not otherwise, since a refresh resets every mounted + // Only when a fact or the portrait changed: the check-in card on this page + // seeds from the signed-in user, and saving it from a stale copy would send + // the imported facts back as nulls, and the portrait slot and "Adjust" read + // their digests from it. Not otherwise, since a refresh resets every mounted // form seeded from that user. if (checkInWasWritten(applied)) await refreshUser(); setResult(applied); @@ -488,9 +490,11 @@ export function DataImportCard() { and you are told what the conversion could not carry. A .zip is either a full OpenDiving archive — which restores your dive-computer files and certification scans as well — or a folder - of dive-computer files, read as one logbook. Records already in - your logbook are matched rather than duplicated, and a dive you - deleted comes back under its own identity. + of dive-computer files, read as one logbook. The check-in details + a logbook carries, and an archive’s portrait, are shown + beside yours first, and you choose which are saved. Records + already in your logbook are matched rather than duplicated, and a + dive you deleted comes back under its own identity.

    diff --git a/src/components/settings/import-check-in-details.tsx b/src/components/settings/import-check-in-details.tsx index f85a8058..9dee0729 100644 --- a/src/components/settings/import-check-in-details.tsx +++ b/src/components/settings/import-check-in-details.tsx @@ -3,14 +3,19 @@ import { useMemo, useState } from "react"; import { useForm, type Resolver, type UseFormReturn } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { UserSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Form } from "@/components/ui/form"; import { UserField } from "@/components/user/user-fields-form"; +import { PortraitFrame, PortraitImage } from "@/components/user/portrait-image"; +import { useAuth } from "@/contexts/AuthContext"; import type { ImportCheckInDetail, ImportCheckInDetailKey, ImportCheckInSubmission, + ImportPortraitChoice, + ImportPortraitOffer, } from "@/lib/api/logbook-import"; import { CHECK_IN_DETAIL_FIELDS, @@ -18,37 +23,51 @@ import { checkInAccountSummary, checkInProposalValues, checkInSubmission, + portraitChoice, } from "@/lib/import-check-in"; +import { cn } from "@/lib/utils"; import { userFieldsSchema, type UserFieldValues, } from "@/lib/validations/user-fields"; +/** What the apply sends beside the file and the token; each part omitted when empty. */ +export interface ImportCheckInChoices { + details?: ImportCheckInSubmission; + portrait?: ImportPortraitChoice; +} + export interface ImportCheckIn { details: readonly ImportCheckInDetail[]; form: UseFormReturn; kept: ReadonlySet; toggleKept: (detail: ImportCheckInDetailKey) => void; + portrait: ImportPortraitOffer | null; + portraitKept: boolean; + togglePortraitKept: () => void; /** - * Validates what is on screen and resolves to the facts to send: `undefined` when - * there are none, `null` when a field is invalid and now says why. + * Validates what is on screen and resolves to what to send, or to `null` when a + * field is invalid and now says why. */ - collect: () => Promise; + collect: () => Promise; } /** * The editable half of an import preview: one form over the check-in facts the - * document carries, seeded with the API's proposal. + * document carries, seeded with the API's proposal, and the archive's portrait, + * taken unless kept. * * Seeded once, so the host mounts it per preview (keyed on the token). The resolver * covers only the facts not kept, so a kept fact's proposal cannot block the apply. */ export function useImportCheckIn( details: readonly ImportCheckInDetail[], + portrait: ImportPortraitOffer | null, ): ImportCheckIn { const [kept, setKept] = useState>( () => new Set(), ); + const [portraitKept, setPortraitKept] = useState(false); const resolver = useMemo(() => { const fields = details @@ -75,33 +94,127 @@ export function useImportCheckIn( }); }; - const collect = async () => { + const collect = async (): Promise => { if (!(await form.trigger())) return null; const submission = checkInSubmission(details, kept, form.getValues()); - return Object.keys(submission).length > 0 ? submission : undefined; + return { + details: Object.keys(submission).length > 0 ? submission : undefined, + portrait: portraitChoice(portrait, portraitKept), + }; }; - return { details, form, kept, toggleKept, collect }; + return { + details, + form, + kept, + toggleKept, + portrait, + portraitKept, + togglePortraitKept: () => setPortraitKept((current) => !current), + collect, + }; +} + +// The archive's portrait beside the account's, the archive's chosen. "Keep mine" +// still sends a choice, `keep`: the apply is told which portrait the diver saw. +function ImportPortraitRow({ + offer, + kept, + onToggle, +}: { + offer: ImportPortraitOffer; + kept: boolean; + onToggle: () => void; +}) { + const { user } = useAuth(); + const mine = offer.account_sha256; + const chosen = "ring-2 ring-primary ring-offset-2 ring-offset-background"; + + return ( +
    + Portrait +
    +
    + {mine ? ( + + ) : ( + + + + )} +
    + Yours now +
    +
    +
    + + {/* eslint-disable-next-line @next/next/no-img-element -- an inline data URL */} + Portrait from this file + +
    + {"This file's"} +
    +
    +
    +

    + {kept + ? mine + ? "Keeping yours; nothing from this file is saved." + : "Left unset; nothing from this file is saved." + : mine + ? "This file's replaces yours when you import." + : "This file's is saved when you import."} +

    + +
    + ); } -// Each fact the document carries, the account's value beside the proposal. What is -// in the boxes when the diver imports is what is written; "Keep mine" takes a fact -// out of the apply altogether, which is different from emptying it - an emptied -// fact is cleared from the account. +// Each fact the document carries, the account's value beside the proposal, and the +// archive's portrait beside the account's. What is in the boxes when the diver +// imports is what is written; "Keep mine" takes a fact out of the apply altogether, +// which is different from emptying it - an emptied fact is cleared from the account. export function ImportCheckInDetails({ checkIn }: { checkIn: ImportCheckIn }) { - const { details, form, kept, toggleKept } = checkIn; - if (details.length === 0) return null; + const { details, form, kept, toggleKept, portrait } = checkIn; + if (details.length === 0 && !portrait) return null; return (

    Check-in details

    - This file carries details a dive shop asks for. What is in the boxes - is saved to your account when you import; change or clear any of it, - or keep yours. + This file carries what a dive shop asks for at check-in. What is + chosen below is saved to your account when you import + {details.length > 0 + ? "; change or clear any detail, or keep yours." + : ", unless you keep yours."}

    + {portrait && ( + + )} {details.map((entry) => { const fields = CHECK_IN_DETAIL_FIELDS[entry.detail]; const mine = checkInAccountSummary(entry); diff --git a/src/lib/api/logbook-import.test.ts b/src/lib/api/logbook-import.test.ts index 18e07ae2..571a2967 100644 --- a/src/lib/api/logbook-import.test.ts +++ b/src/lib/api/logbook-import.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { importSourceLabel, + logbookImportAPI, LOGBOOK_IMPORT_ACCEPT, LOGBOOK_IMPORT_SOURCE_EXTENSIONS, MAX_IMPORT_ARCHIVE_SIZE, @@ -8,6 +9,13 @@ import { type ImportSourceFormat, } from "./logbook-import"; +vi.mock("./client", () => ({ + apiClient: { post: vi.fn().mockResolvedValue({ data: {} }) }, +})); + +const { apiClient } = await import("./client"); +const post = vi.mocked(apiClient.post); + describe("LOGBOOK_IMPORT_ACCEPT", () => { it("offers a file extension for every format the API converts", () => { // The pin `DIVE_FILE_ACCEPT` gets in `dives.test.ts`, for the other import @@ -78,3 +86,26 @@ describe("the import size ceilings", () => { expect(MAX_IMPORT_ARCHIVE_SIZE).toBe(500 * 1024 * 1024); }); }); + +describe("logbookImportAPI.apply", () => { + const file = new File(["{}"], "logbook.zip"); + const sentForm = () => post.mock.lastCall![1] as FormData; + + it("sends the portrait's choice with the digest the preview showed", async () => { + await logbookImportAPI.apply(file, "tok", undefined, { + choice: "keep", + account_sha256: null, + }); + // `account_sha256` goes even when null: the API requires the key. + expect(JSON.parse(sentForm().get("portrait") as string)).toEqual({ + choice: "keep", + account_sha256: null, + }); + expect(sentForm().get("check_in_details")).toBeNull(); + }); + + it("sends no portrait field without a choice, which keeps the account's", async () => { + await logbookImportAPI.apply(file, "tok"); + expect(sentForm().has("portrait")).toBe(false); + }); +}); diff --git a/src/lib/api/logbook-import.ts b/src/lib/api/logbook-import.ts index eea0708e..4de3cc9b 100644 --- a/src/lib/api/logbook-import.ts +++ b/src/lib/api/logbook-import.ts @@ -150,9 +150,12 @@ export type ImportNoteCode = // An emergency contact or insurance the document carries but the preview does not // offer: it names nobody, or it is not the first. A warning. | "check_in_detail_dropped" - // Only when a column actually changed, which is what tells the card to re-read - // the signed-in user. - | "check_in_detail_written"; + // Only when a fact or the portrait actually changed, which is what tells the card + // to re-read the signed-in user. + | "check_in_detail_written" + // The archive's portrait was taken, and the account's had changed since the + // preview, so the account's stayed. Information: nothing was lost. + | "portrait_kept"; /** One thing the import decided, addressed to the diver. */ export interface ImportNote { @@ -333,6 +336,31 @@ export interface ImportCheckInSubmission { insurance?: ImportCheckInInsurance | null; } +/** + * The archive's portrait beside the account's, for the diver to take or keep. + * + * A field next to `check_in_details` rather than an entry in it, as the API has it. + */ +export interface ImportPortraitOffer { + /** + * The account's portrait as its digest - the `?v=` of `GET /user/portrait` - or + * `null` without one. Sent back as the choice's `account_sha256`. + */ + account_sha256: string | null; + /** The archive's portrait as a `data:image/webp` URL, framed as it would be stored. */ + proposed: string; +} + +/** + * What the diver chose for the offered portrait. `account_sha256` is the offer's, + * `null` included, on *keep* as on *take*: the API requires the key, and a choice + * without it is a 422 that fails the whole import. + */ +export interface ImportPortraitChoice { + choice: "take" | "keep"; + account_sha256: string | null; +} + /** What `POST /import/logbook/preview` returns. Nothing has been written. */ export interface ImportPreview extends ImportReport { /** @@ -362,6 +390,11 @@ export interface ImportPreview extends ImportReport { * phone, emergency contact, insurance. Empty when it carries none. */ check_in_details: ImportCheckInDetail[]; + /** + * The archive's portrait, or `null` when the upload carries none the API can offer + * - `notes` say why - or carries the account's own at the same crop. + */ + portrait: ImportPortraitOffer | null; } /** What `POST /import/logbook` returns. Everything in it has been committed. */ @@ -422,16 +455,20 @@ export const logbookImportAPI = { * what stops a diver approving one document and uploading another. * * `checkIn` is the facts to write, as a JSON field; omitted, none is written. + * `portrait` is the choice for the preview's `portrait`; omitted, the account + * keeps its own. */ async apply( file: File, token: string, checkIn?: ImportCheckInSubmission, + portrait?: ImportPortraitChoice, ): Promise { const formData = new FormData(); formData.append("file", file); formData.append("token", token); if (checkIn) formData.append("check_in_details", JSON.stringify(checkIn)); + if (portrait) formData.append("portrait", JSON.stringify(portrait)); const response = await apiClient.post( "/import/logbook", diff --git a/src/lib/import-check-in.test.ts b/src/lib/import-check-in.test.ts index f54cc833..2be26a30 100644 --- a/src/lib/import-check-in.test.ts +++ b/src/lib/import-check-in.test.ts @@ -5,6 +5,7 @@ import { checkInProposalValues, checkInSubmission, checkInWasWritten, + portraitChoice, } from "./import-check-in"; import type { ImportCheckInDetail, @@ -115,8 +116,36 @@ describe("checkInWasWritten", () => { expect(checkInWasWritten(report(["check_in_detail_written"]))).toBe(true); expect( checkInWasWritten( - report(["check_in_detail_dropped", "diver_not_applied"]), + report([ + "check_in_detail_dropped", + "diver_not_applied", + "portrait_kept", + ]), ), ).toBe(false); }); }); + +describe("portraitChoice", () => { + const sha = "a".repeat(64); + + it("carries the preview's digest whichever way the diver chose", () => { + const offer = { account_sha256: sha, proposed: "data:image/webp;base64," }; + expect(portraitChoice(offer, false)).toEqual({ + choice: "take", + account_sha256: sha, + }); + expect(portraitChoice(offer, true)).toEqual({ + choice: "keep", + account_sha256: sha, + }); + // No portrait on the account is `null`, sent rather than left out. + expect( + portraitChoice({ ...offer, account_sha256: null }, true), + ).toHaveProperty("account_sha256", null); + }); + + it("sends nothing when nothing was offered", () => { + expect(portraitChoice(null, false)).toBeUndefined(); + }); +}); diff --git a/src/lib/import-check-in.ts b/src/lib/import-check-in.ts index fcb5ae67..8dbeddc7 100644 --- a/src/lib/import-check-in.ts +++ b/src/lib/import-check-in.ts @@ -2,6 +2,8 @@ import type { ImportCheckInDetail, ImportCheckInDetailKey, ImportCheckInSubmission, + ImportPortraitChoice, + ImportPortraitOffer, ImportReport, } from "@/lib/api/logbook-import"; import { formatDateOnly } from "@/lib/date-time"; @@ -142,7 +144,22 @@ export function checkInSubmission( return submission; } -/** Whether an apply changed any of the account's check-in facts. */ +/** + * The apply's `portrait` field: the choice, with the account digest the preview + * showed whichever way it went, or `undefined` when nothing was offered. + */ +export function portraitChoice( + offer: ImportPortraitOffer | null, + kept: boolean, +): ImportPortraitChoice | undefined { + if (!offer) return undefined; + return { + choice: kept ? "keep" : "take", + account_sha256: offer.account_sha256, + }; +} + +/** Whether an apply changed any of the account's check-in facts or its portrait. */ export function checkInWasWritten(report: ImportReport): boolean { return report.notes.some((note) => note.code === "check_in_detail_written"); } diff --git a/src/lib/logbook-import.test.ts b/src/lib/logbook-import.test.ts index 3a5e7492..24ad7823 100644 --- a/src/lib/logbook-import.test.ts +++ b/src/lib/logbook-import.test.ts @@ -158,6 +158,7 @@ describe("noteIsWarning", () => { "recording_attached", "recording_filled", "check_in_detail_written", + "portrait_kept", ].some(noteIsWarning), ).toBe(false); }); @@ -336,6 +337,7 @@ describe("importSourceSentence", () => { archive: false, token: "tok-1", check_in_details: [], + portrait: null, ...overrides, }; } diff --git a/src/lib/logbook-import.ts b/src/lib/logbook-import.ts index b678df7f..bb2acc44 100644 --- a/src/lib/logbook-import.ts +++ b/src/lib/logbook-import.ts @@ -86,13 +86,15 @@ export function collectionRowIsEmpty(row: ImportCollectionReport): boolean { * The split is "did the diver end up with less than the document described". * A skipped record, a dropped value, an unresolved reference or species, a * file that could not be stored and a check-in detail the preview does not offer - * all lose something, so they are warnings. A linked or restored record, either - * remap, a `diver` deliberately not applied, a check-in detail written as - * confirmed and a file whose bytes simply are not in a bare document are all the - * import working as designed - `file_not_contained` especially, which is the - * *expected* state of - * every referenced file when a document rather than an archive was imported, and - * colouring it as a failure would make the ordinary case look broken. + * all lose something, so they are warnings - a portrait the API refuses among + * them, noted as a skipped file. A linked or restored record, either remap, the + * `diver` member's identity and settings deliberately not applied, a check-in + * detail or portrait written as confirmed, a portrait kept because the account's + * changed after the preview, and a file whose bytes simply are not in a bare + * document are all the import working as designed - `file_not_contained` + * especially, which is the *expected* state of every referenced file when a + * document rather than an archive was imported, and colouring it as a failure + * would make the ordinary case look broken. * * An unknown code from a newer API reads as information rather than as a warning: * inventing alarm for a note this build cannot interpret is the worse error. From cb2b3a69bbccd89a405297141c4450b20306dc3e Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Thu, 24 Sep 2026 19:30:21 +0300 Subject: [PATCH 2/2] fix(privacy): an archive's portrait reaches the server whether or not it is taken Co-Authored-By: Claude Opus 5.5 --- src/app/privacy/page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index e476c814..d5f68f01 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -391,9 +391,10 @@ export default async function PrivacyPage() {
  • GPS positions inside a photo you pick as your profile picture or - portrait, or inside the portrait a full archive carries when you - take it on import; they travel with the upload and are removed - by this server before the photo is stored + portrait, or inside the portrait carried by a full archive you + import, which travel with the upload and are removed by this + server before the photo is stored — an archive’s + only if you take it in the import preview