Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions apps/postcards/src/features/backup/archiveZip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import { buildFile } from "./exportJson";
export const ARCHIVE_FILENAME = "postcards-backup.zip";
export const MANIFEST_NAME = "backup.postcards.json";

// Extensions must cover EVERY mime the photo schema admits (png|jpe?g|webp|gif|
// avif — note both image/jpeg AND image/jpg pass its regex), so a written file
// always maps back to a schema-valid image mime on read. An unknown image mime
// falls back to its subtype so it still round-trips rather than becoming ".bin".
const EXT_OF: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
Expand All @@ -29,17 +34,29 @@ const MIME_OF: Record<string, string> = {
avif: "image/avif",
};

const extForMime = (mime: string): string =>
EXT_OF[mime] ?? (mime.replace(/^image\//, "").replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin");
const mimeForExt = (ext: string): string => MIME_OF[ext] ?? `image/${ext}`;

const B64_CHUNK = 0x8000;

/** Decode a `data:<mime>;base64,<payload>` URL into raw bytes + its mime. */
/** Decode a `data:<mime>[;base64],<payload>` URL into raw bytes + its (parameter-
* stripped) mime. Handles both base64 and percent-encoded/plain payloads so a
* schema-valid but non-base64 photo can't throw and abort the whole archive. */
function decodeDataUrl(dataUrl: string): { bytes: Uint8Array; mime: string } {
const comma = dataUrl.indexOf(",");
const meta = dataUrl.slice(5, comma); // between "data:" and ","
const mime = meta.replace(/;base64$/i, "") || "application/octet-stream";
const bin = atob(dataUrl.slice(comma + 1));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return { bytes, mime };
const isBase64 = /;base64$/i.test(meta);
// Strip the ;base64 flag AND any ;charset=… parameters to get the bare mime.
const mime = meta.replace(/;base64$/i, "").split(";")[0] || "application/octet-stream";
const payload = dataUrl.slice(comma + 1);
if (isBase64) {
const bin = atob(payload);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return { bytes, mime };
}
return { bytes: new TextEncoder().encode(decodeURIComponent(payload)), mime };
}

/** Re-encode raw image bytes as an inline base64 data URL. */
Expand All @@ -66,8 +83,7 @@ export function buildArchive(
const stash = (photos?: PhotoLike[]) =>
photos?.map((p) => {
const { bytes, mime } = decodeDataUrl(p.src);
const ext = EXT_OF[mime] ?? "bin";
const name = `photos/${String(++n).padStart(4, "0")}.${ext}`;
const name = `photos/${String(++n).padStart(4, "0")}.${extForMime(mime)}`;
photoEntries.push({ name, data: bytes });
return { src: `zip:${name}`, caption: p.caption ?? null };
});
Expand Down Expand Up @@ -101,7 +117,7 @@ export function archiveToJson(bytes: Uint8Array): string {
const data = fileMap.get(name);
if (!data) return null; // referenced image missing — drop rather than break restore
const ext = name.split(".").pop()?.toLowerCase() ?? "";
return { src: bytesToDataUrl(data, MIME_OF[ext] ?? "application/octet-stream"), caption: ph.caption ?? null };
return { src: bytesToDataUrl(data, mimeForExt(ext)), caption: ph.caption ?? null };
}
return ph;
})
Expand All @@ -111,6 +127,19 @@ export function archiveToJson(bytes: Uint8Array): string {
return r.photos ? { ...r, photos: reinline(r.photos) } : r;
};
if (Array.isArray(obj.visits)) obj.visits = obj.visits.map(withPhotos);
if (Array.isArray(obj.stories)) obj.stories = obj.stories.map(withPhotos);
if (Array.isArray(obj.stories)) {
obj.stories = (obj.stories as unknown[])
.map(withPhotos)
// A story may be image-only (no title/text). If its images went missing from
// the archive, re-inlining empties it — which the schema rejects, aborting the
// WHOLE restore. Drop such a now-empty story instead so the rest still loads.
.filter((rec) => {
const s = rec as { title?: unknown; text?: unknown; photos?: unknown[] };
const hasText = typeof s.title === "string" && s.title.trim().length > 0;
const hasBody = typeof s.text === "string" && s.text.trim().length > 0;
const hasPhotos = Array.isArray(s.photos) && s.photos.length > 0;
return hasText || hasBody || hasPhotos;
});
}
return JSON.stringify(obj);
}
37 changes: 26 additions & 11 deletions apps/postcards/src/features/stats/CountryCoverageMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,38 @@ export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string
}, [land, iso2, ref]);

const layout = useMemo(() => {
const xs: number[] = [];
const ys: number[] = [];
const push = (lon: number, lat: number) => {
xs.push((lon * Math.PI) / 180);
ys.push(mercY(lat));
};
// Frame to the MAINLAND — the ring with the most points — so a country with
// far-flung overseas territories (France, the US…) doesn't zoom out to the
// whole globe. Everything else still draws, clipped by the viewBox.
let mainRing: Position[] | null = null;
for (const r of rings) if (r.length > (mainRing?.length ?? 0)) mainRing = r;
if (mainRing) for (const p of mainRing) push(p[0]!, p[1]!);
const frameLons: number[] = [];
const frameLats: number[] = [];
if (mainRing)
for (const p of mainRing) {
frameLons.push(p[0]!);
frameLats.push(p[1]!);
}
else {
for (const p of model.visitedPoints) push(p.lon, p.lat);
for (const m of model.missing) push(m.lon, m.lat);
for (const p of model.visitedPoints) {
frameLons.push(p.lon);
frameLats.push(p.lat);
}
for (const m of model.missing) {
frameLons.push(m.lon);
frameLats.push(m.lat);
}
}
if (!xs.length) return null;
if (!frameLons.length) return null;
// Unwrap across the antimeridian: when the frame spans > 180° of raw longitude
// the land wraps the date line (Russia, Fiji…), so shift western lons by +360
// and project EVERYTHING (rings, dots, blobs) in that continuous space — else
// the silhouette collapses to a distorted, off-centre sliver.
const unwrap = Math.max(...frameLons) - Math.min(...frameLons) > 180;
const wrapLon = (lon: number) => (unwrap && lon < 0 ? lon + 360 : lon);

const xs = frameLons.map((lon) => (wrapLon(lon) * Math.PI) / 180);
const ys = frameLats.map(mercY);
let minX = Math.min(...xs);
let maxX = Math.max(...xs);
let minY = Math.min(...ys);
Expand All @@ -124,7 +139,7 @@ export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string
const scale = Math.min((W - 2 * PAD) / (maxX - minX), (H - 2 * PAD) / (maxY - minY));
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const sx = (lon: number) => W / 2 + ((lon * Math.PI) / 180 - midX) * scale;
const sx = (lon: number) => W / 2 + ((wrapLon(lon) * Math.PI) / 180 - midX) * scale;
const sy = (lat: number) => H / 2 - (mercY(lat) - midY) * scale;
const degToPx = (scale * Math.PI) / 180; // ~px per degree at this scale

Expand Down
7 changes: 4 additions & 3 deletions apps/postcards/src/features/stats/StatStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ export function StatStrip() {
type="button"
className="ss-item"
onClick={() => {
// World-level shortcut — drop any country drill-down so it shows the
// whole world, not the last country you opened from a stats card.
useFilters.getState().set({ country: "" });
// World-level shortcut — drop the WHOLE country drill-down a stats card
// may have set (country AND minPop), so the list matches this counter's
// number instead of staying gated at the last tier you opened.
useFilters.getState().set({ country: "", minPop: 0 });
openPlaces(view);
}}
title={t("statStrip.openAria", { label })}
Expand Down
8 changes: 5 additions & 3 deletions apps/postcards/src/features/stats/StatsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,12 @@ export function StatsView() {
useFilters.getState().set({ minPop, country: "" });
useUi.getState().openPlaces("visited");
}
// The coverage-hero + KPI tiles are all world-level: drop any country
// drill-down before opening the view so they never stay narrowed to one country.
// The coverage-hero + KPI tiles are all world-level: drop the WHOLE country
// drill-down (country AND its population tier) before opening the view, so a
// world "Cities" bar never opens a list still truncated to 1M+ from a prior
// mega-city drill (mirrors openCitiesFiltered, which already resets both).
function openWorld(view: PlacesView) {
useFilters.getState().set({ country: "" });
useFilters.getState().set({ country: "", minPop: 0 });
useUi.getState().openPlaces(view);
}
const continentCov = useMemo(
Expand Down
5 changes: 4 additions & 1 deletion apps/postcards/src/features/travel/RouteMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export function RouteMap({

map.on("load", () => {
void getLand().then((land) => {
if (!mapRef.current || !land) return;
// Guard on IDENTITY, not just truthiness: a fast Map→List→Map toggle can
// remove this map and create a new one while the (shared, cached) land
// promise is in flight — touching the removed map would throw.
if (mapRef.current !== map || !land) return;
if (map.getSource("land")) return;
map.addSource("land", { type: "geojson", data: land });
map.addLayer({
Expand Down
6 changes: 6 additions & 0 deletions apps/postcards/src/features/travel/distance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export function coordsOf(place: PlaceRef, ref: ReferenceData): { lon: number; la
// Some sites have no coordinate in the source (stored as 0,0) — treat as unknown.
return h && (h.lat !== 0 || h.lon !== 0) ? { lon: h.lon, lat: h.lat } : null;
}
// A user-authored "custom" pin carries its own coordinates on the record (there's
// no reference entry to look up). The trip pool already resolves these the same
// way, so a custom stop's leg must draw + measure here too — not vanish.
if (place.kind === "custom") {
return place.lat != null && place.lon != null ? { lon: place.lon, lat: place.lat } : null;
}
return null; // countries have no single coordinate
}

Expand Down
38 changes: 37 additions & 1 deletion apps/postcards/tests/unit/archiveZip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { zipStore, unzipStore, looksLikeZip, crc32 } from "../../src/lib/backup/zip";
import { buildArchive, archiveToJson, MANIFEST_NAME } from "../../src/features/backup/archiveZip";
import { importFile } from "../../src/features/backup/importJson";
import type { Visit } from "../../src/lib/schema/models";
import type { Story, Visit } from "../../src/lib/schema/models";

const enc = (s: string) => new TextEncoder().encode(s);
const dec = (b: Uint8Array) => new TextDecoder().decode(b);
Expand Down Expand Up @@ -91,4 +91,40 @@ describe('"Save everything" archive round-trip (data + photos-as-files)', () =>
expect(result.ok).toBe(true);
if (result.ok) expect(result.visits[0]!.photos ?? []).toHaveLength(0);
});

it("round-trips an image/jpg photo to a schema-valid mime (regression: was .bin → reject)", () => {
// The schema accepts data:image/jpg (jpe?g); the archive must not turn it into
// an un-restorable .bin. AAAA is valid base64 (3 zero bytes).
const jpg = "data:image/jpg;base64,AAAA";
const bytes = buildArchive([{ ...visit(), photos: [{ src: jpg, caption: null }] }], [], []);
// Stored as .jpg, not .bin.
expect(unzipStore(bytes).some((e) => e.name === "photos/0001.jpg")).toBe(true);
const result = importFile(archiveToJson(bytes));
expect(result.ok).toBe(true);
if (result.ok) expect(result.visits[0]!.photos![0]!.src).toMatch(/^data:image\/jpeg;base64,/);
});

it("does NOT throw on a schema-valid non-base64 photo (regression: atob threw, aborting export)", () => {
const nonB64 = "data:image/png;charset=utf-8,hello";
expect(() => buildArchive([{ ...visit(), photos: [{ src: nonB64, caption: null }] }], [], [])).not.toThrow();
});

it("drops an image-only story whose image went missing rather than aborting the whole restore", () => {
const story: Story = {
storyId: crypto.randomUUID(),
place: { kind: "city", id: "paris-fr", name: "Paris", countryId: "FR" },
date: "2019-08-12",
photos: [{ src: dataUrl, caption: null }], // image-only (no title/text)
addedAt: new Date().toISOString(),
} as Story;
const bytes = buildArchive([visit()], [], [story]);
// Rebuild the archive WITHOUT the story's image (only the manifest survives).
const stripped = zipStore(unzipStore(bytes).filter((e) => e.name === MANIFEST_NAME));
const result = importFile(archiveToJson(stripped));
expect(result.ok).toBe(true); // the visit still restores…
if (result.ok) {
expect(result.visits).toHaveLength(1);
expect(result.stories).toHaveLength(0); // …and the now-empty story is dropped, not fatal
}
});
});
11 changes: 11 additions & 0 deletions apps/postcards/tests/unit/tripLegs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ const ref = {
heritageById: () => undefined,
} as unknown as ReferenceData;

describe("custom stops draw + measure (regression: coordsOf ignored kind=custom)", () => {
const cA: PlaceRef = { kind: "custom", id: "a", name: "Cabin", countryId: "FR", lon: 2, lat: 48 };
const cB: PlaceRef = { kind: "custom", id: "b", name: "Lake", countryId: "FR", lon: 9, lat: 45 };
it("a leg between two custom pins draws an arc and contributes distance", async () => {
const { tripPathKm } = await import("../../src/features/travel/distance");
expect(stopsArcs([cA, cB], ref, "car").features).toHaveLength(1);
expect(tripPathKm([cA, cB], ref).km).toBeGreaterThan(0);
expect(tripPathKm([cA, cB], ref).unresolvedLegs).toBe(0);
});
});

describe("stopsArcs tags each leg with its own mode", () => {
it("uses legModes[i] when present, else the trip default", () => {
const fc = stopsArcs([P, T, O], ref, "flight", ["flight", "train"]);
Expand Down
Loading