diff --git a/apps/postcards/src/features/backup/Backup.tsx b/apps/postcards/src/features/backup/Backup.tsx index 47ecb7b..fa4ea30 100644 --- a/apps/postcards/src/features/backup/Backup.tsx +++ b/apps/postcards/src/features/backup/Backup.tsx @@ -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, @@ -55,6 +55,31 @@ async function deliver(filename: string, text: string, type: string): Promise { + 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(), []); @@ -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"); @@ -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. @@ -244,7 +296,10 @@ export function Backup() {

{t("backup.intro")}

- +