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
69 changes: 63 additions & 6 deletions apps/postcards/src/features/backup/Backup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getReferenceData } from "../../lib/reference/referenceData";
import { backfillUpdatedAt } from "../../lib/schema/helpers";
import { replaceAllPortable } from "../../lib/db/visitsDb";
import { toMarkdown } from "./exportMarkdown";
import { download } from "../../lib/download";
import { download, downloadBlob } from "../../lib/download";
import { DurabilityNote } from "../../ui/DurabilityNote";
import {
markBackedUp,
Expand Down Expand Up @@ -55,6 +55,31 @@ async function deliver(filename: string, text: string, type: string): Promise<vo
download(filename, text, type);
}

/** Same delivery, but for a BINARY file (the .zip archive): native writes the
* bytes as base64 then shares; the web shares/downloads the Blob directly. */
async function deliverBlob(filename: string, blob: Blob, type: string): Promise<void> {
if (Capacitor.isNativePlatform()) {
const bytes = new Uint8Array(await blob.arrayBuffer());
let bin = "";
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
const { uri } = await Filesystem.writeFile({ path: filename, data: btoa(bin), directory: Directory.Cache });
await Share.share({ title: filename, url: uri });
return;
}
if (typeof navigator !== "undefined" && typeof navigator.canShare === "function") {
const file = new File([blob], filename, { type });
if (navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: filename });
return;
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
}
}
}
downloadBlob(filename, blob);
}

export function Backup() {
const t = useT();
const ref = useMemo(() => getReferenceData(), []);
Expand Down Expand Up @@ -86,6 +111,19 @@ export function Backup() {
setMessage({ kind: "err", text: t("backup.msg.exportJsonErr") });
}
}
async function exportArchive() {
try {
const { buildArchive, ARCHIVE_FILENAME } = await import("./archiveZip");
const bytes = buildArchive(visits, trips, stories);
// Copy into a fresh ArrayBuffer so the Blob owns exactly these bytes.
await deliverBlob(ARCHIVE_FILENAME, new Blob([bytes.slice()], { type: "application/zip" }), "application/zip");
// A full archive (data + photos) is a real backup — reset the reminder clock.
markBackedUp(Date.now());
setReminderDue(false);
} catch {
setMessage({ kind: "err", text: t("backup.msg.exportZipErr") });
}
}
async function exportMd() {
try {
await deliver("places.md", toMarkdown(visits, trips, ref), "text/markdown");
Expand Down Expand Up @@ -125,7 +163,21 @@ export function Backup() {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const text = await file.text();
const buf = new Uint8Array(await file.arrayBuffer());
// A .zip "Save everything" archive is a FULL RESTORE — unpack it back into the
// standard JSON (re-inlining the photo files) and restore that. Detected by the
// ZIP magic bytes, not the extension, so a mislabelled file still works.
const { looksLikeZip } = await import("../../lib/backup/zip");
if (looksLikeZip(buf)) {
try {
const { archiveToJson } = await import("./archiveZip");
await restoreFromJson(archiveToJson(buf));
} catch {
setMessage({ kind: "err", text: t("backup.msg.zipUnreadable") });
}
return;
}
const text = new TextDecoder().decode(buf);
// A JSON backup ({…}) is a FULL RESTORE (replaces everything); anything else
// is treated as a places table (CSV/TSV) and MERGED in. The format is picked
// from the content, not the extension, so a mislabelled file still works.
Expand Down Expand Up @@ -244,7 +296,10 @@ export function Backup() {
<p className="muted">{t("backup.intro")}</p>

<div className="btn-row">
<button className="btn" type="button" onClick={() => void exportJson()}>
<button className="btn" type="button" onClick={() => void exportArchive()}>
{t("backup.export.all")}
</button>
<button className="btn-ghost" type="button" onClick={() => void exportJson()}>
{t("backup.export.data")}
</button>
<button className="btn-ghost" type="button" onClick={() => void exportCsv()}>
Expand All @@ -259,7 +314,7 @@ export function Backup() {
<input
ref={fileInput}
type="file"
accept="application/json,.json,text/csv,.csv,.tsv,text/plain"
accept="application/zip,.zip,application/json,.json,text/csv,.csv,.tsv,text/plain"
onChange={onImport}
style={{ display: "none" }}
aria-hidden="true"
Expand All @@ -277,8 +332,10 @@ export function Backup() {
</p>
)}
<p className="muted small">
Import understands two things. A <strong>.json backup</strong> is a full restore — it{" "}
<strong>⚠ replaces everything on this device</strong> (you'll be asked to confirm). A{" "}
<strong>Save everything</strong> writes a <strong>.zip</strong> holding your data plus every
photo as a real image file — the most complete, portable backup. Import understands three
things. A <strong>.zip archive</strong> or a <strong>.json backup</strong> is a full restore —
it <strong>⚠ replaces everything on this device</strong> (you'll be asked to confirm). A{" "}
<strong>.csv places list</strong> (columns like <code>lat, lon, country, city, been</code>,
where <code>been</code> tags are <code>been</code> / <code>want</code> / <code>fave</code>){" "}
is merged in — it only adds and updates places, never erasing your trips or stories. Files
Expand Down
116 changes: 116 additions & 0 deletions apps/postcards/src/features/backup/archiveZip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Story, Trip, Visit } from "../../lib/schema/models";
import { zipStore, unzipStore, type ZipEntry } from "../../lib/backup/zip";
import { buildFile } from "./exportJson";

// The "Save everything" archive: one .zip holding a compact JSON manifest plus
// every photo as a real, openable image FILE (photos/0001.jpg …). The manifest
// is the canonical portable file with each photo's inline data URL swapped for a
// "zip:photos/…" reference, so the JSON stays small and readable while the images
// live as browsable files. Import reverses it: re-inline the referenced bytes
// into data URLs, then hand the reconstructed standard JSON to the normal
// validator (Constitution VI: still parsed, never executed).

export const ARCHIVE_FILENAME = "postcards-backup.zip";
export const MANIFEST_NAME = "backup.postcards.json";

const EXT_OF: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/avif": "avif",
};
const MIME_OF: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
};

const B64_CHUNK = 0x8000;

/** Decode a `data:<mime>;base64,<payload>` URL into raw bytes + its mime. */
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 };
}

/** Re-encode raw image bytes as an inline base64 data URL. */
function bytesToDataUrl(bytes: Uint8Array, mime: string): string {
let bin = "";
for (let i = 0; i < bytes.length; i += B64_CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + B64_CHUNK));
}
return `data:${mime};base64,${btoa(bin)}`;
}

type PhotoLike = { src: string; caption: string | null };

/** Build the complete "everything" archive as ZIP bytes. */
export function buildArchive(
visits: Visit[],
trips: Trip[] = [],
stories: Story[] = [],
now = new Date(),
): Uint8Array {
const file = buildFile(visits, trips, stories, now); // validated, data-URL photos
const photoEntries: ZipEntry[] = [];
let n = 0;
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}`;
photoEntries.push({ name, data: bytes });
return { src: `zip:${name}`, caption: p.caption ?? null };
});
const manifest = {
...file,
visits: file.visits.map((v) => (v.photos && v.photos.length ? { ...v, photos: stash(v.photos) } : v)),
stories: file.stories.map((s) => (s.photos && s.photos.length ? { ...s, photos: stash(s.photos) } : s)),
};
const json = new TextEncoder().encode(JSON.stringify(manifest, null, 2));
return zipStore([{ name: MANIFEST_NAME, data: json }, ...photoEntries]);
}

/**
* Turn an archive's bytes back into a standard Postcards JSON string, re-inlining
* each "zip:photos/…" reference from its stored image file. The result is fed to
* the normal `importFile` validator. Throws if there's no manifest inside.
*/
export function archiveToJson(bytes: Uint8Array): string {
const entries = unzipStore(bytes);
const manifest =
entries.find((e) => e.name === MANIFEST_NAME) ?? entries.find((e) => e.name.endsWith(".json"));
if (!manifest) throw new Error("no backup manifest in archive");
const fileMap = new Map(entries.map((e) => [e.name, e.data] as const));
const obj = JSON.parse(new TextDecoder().decode(manifest.data)) as Record<string, unknown>;
const reinline = (photos: unknown): PhotoLike[] =>
(Array.isArray(photos) ? photos : [])
.map((p): PhotoLike | null => {
const ph = p as PhotoLike;
if (typeof ph.src === "string" && ph.src.startsWith("zip:")) {
const name = ph.src.slice(4);
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 ph;
})
.filter((p): p is PhotoLike => p !== null);
const withPhotos = (rec: unknown) => {
const r = rec as { photos?: unknown };
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);
return JSON.stringify(obj);
}
2 changes: 1 addition & 1 deletion apps/postcards/src/features/backup/exportJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getReferenceData } from "../../lib/reference/referenceData";
/** Build the canonical portable file object from the current visits + trips + stories.
* `tombstones` is written only for device sync; a plain backup passes none, so the
* exported file stays free of an empty `tombstones` key. */
function buildFile(
export function buildFile(
visits: Visit[],
trips: Trip[] = [],
stories: Story[] = [],
Expand Down
10 changes: 8 additions & 2 deletions apps/postcards/src/features/stats/StatStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo } from "react";
import { useVisits } from "../../lib/store/useVisits";
import { useSettings } from "../../lib/store/useSettings";
import { useUi, type PlacesView } from "../../lib/store/useUi";
import { useFilters } from "../../lib/store/useFilters";
import { getReferenceData } from "../../lib/reference/referenceData";
import { computeCoverage } from "./computeStats";
import { formatInt, formatPercent } from "../../lib/format/format";
Expand Down Expand Up @@ -64,7 +65,12 @@ export function StatStrip() {
<button
type="button"
className="ss-item"
onClick={() => openPlaces(view)}
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: "" });
openPlaces(view);
}}
title={t("statStrip.openAria", { label })}
aria-label={aria}
>
Expand All @@ -90,7 +96,7 @@ export function StatStrip() {
<span className="ss-sep" aria-hidden />
<Counter num={stats.cov.citiesVisited} label={t("statStrip.been")} cls="ss-been" view="visited" />
{stats.cov.airportsVisited > 0 && (
<Counter num={stats.cov.airportsVisited} label={t("statStrip.airports")} cls="ss-air" view="visited" />
<Counter num={stats.cov.airportsVisited} label={t("statStrip.airports")} cls="ss-air" view="airports" />
)}
<Counter num={stats.want} label={t("statStrip.want")} cls="ss-want" view="wishlist" />
<Counter num={stats.fav} label={t("statStrip.fav")} cls="ss-fav" view="favorites" />
Expand Down
18 changes: 12 additions & 6 deletions apps/postcards/src/features/stats/StatsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "./computeStats";
import { travelTotals } from "../travel/distance";
import { MODE_GLYPH } from "../travel/modes";
import { useUi } from "../../lib/store/useUi";
import { useUi, type PlacesView } from "../../lib/store/useUi";
import { useFilters } from "../../lib/store/useFilters";
import { countryFlag, formatDate, formatInt, formatKm, formatPercent } from "../../lib/format/format";
import { CONTINENT_COLORS, CONTINENT_ORDER } from "../../lib/reference/continents";
Expand Down Expand Up @@ -376,6 +376,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.
function openWorld(view: PlacesView) {
useFilters.getState().set({ country: "" });
useUi.getState().openPlaces(view);
}
function flyToRegion(iso2: string) {
return (name: string) => {
const sub = ref.subdivisionsOf(iso2).find((s) => s.name === name);
Expand Down Expand Up @@ -444,7 +450,7 @@ export function StatsView() {
total: formatInt(coverage.worldCountryCount),
pct: worldPctLabel,
})}
onClick={() => useUi.getState().openPlaces("countries")}
onClick={() => openWorld("countries")}
>
<span className="stat-bar-top">
<span className="stat-bar-name">{t("stats.bars.countries")}</span>
Expand All @@ -468,7 +474,7 @@ export function StatsView() {
total: formatInt(coverage.worldCityCount),
pct: cityPctLabel,
})}
onClick={() => useUi.getState().openPlaces("visited")}
onClick={() => openWorld("visited")}
>
<span className="stat-bar-top">
<span className="stat-bar-name">{t("stats.bars.cities")}</span>
Expand Down Expand Up @@ -526,7 +532,7 @@ export function StatsView() {
type="button"
className="kpi"
title={t("stats.kpi.countriesTitle")}
onClick={() => useUi.getState().openPlaces("countries")}
onClick={() => openWorld("countries")}
>
<span className="kpi-num kpi-air">{formatInt(coverage.countriesVisited)}</span>
<span className="kpi-label">{t("stats.kpi.countries")}</span>
Expand Down Expand Up @@ -558,7 +564,7 @@ export function StatsView() {
type="button"
className="kpi"
title={t("stats.kpi.visitedTitle")}
onClick={() => useUi.getState().openPlaces("visited")}
onClick={() => openWorld("airports")}
>
<span className="kpi-num kpi-air">{formatInt(coverage.airportsVisited)}</span>
<span className="kpi-label">{t("stats.kpi.airports")}</span>
Expand All @@ -569,7 +575,7 @@ export function StatsView() {
type="button"
className="kpi"
title={t("stats.kpi.monumentsTitle")}
onClick={() => useUi.getState().openPlaces("monuments")}
onClick={() => openWorld("monuments")}
>
<span className="kpi-num kpi-want">{formatInt(coverage.monumentsVisited)}</span>
<span className="kpi-label">{t("stats.kpi.monuments")}</span>
Expand Down
4 changes: 4 additions & 0 deletions apps/postcards/src/features/visits/PlacesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ function mapRequest(view: PlacesView): { kind?: Kind; status?: Status; collectio
return { kind: "cities", status: "all", collection: null };
case "monuments":
return { kind: "monuments", status: "all", collection: null };
case "airports":
// The airports you've actually been through (the count these tiles show),
// not the whole world of airports.
return { kind: "airports", status: "visited", collection: null };
case "moments":
return { collection: "moments" };
case "passport":
Expand Down
Loading
Loading