{% extends "base.html" %} {% block title %}Export · {{ app_name }}{% endblock %} {# 🔴 Every download here is a PLAIN navigation. Do not "improve" it into fetch() + Blob + URL.createObjectURL: a blob download is precisely what misbehaves on iOS Safari (it opens inline, or saves "Unknown.txt"), and it holds the whole file in page memory on the device least able to spare it. No JS on this page. 🔴 If a download ever arrives as HTML, your tinyauth session expired: Caddy turns a 401 into a 302 to the login page, so the file saves with the right name, the wrong contents and no error anywhere. The JSON's first key is "latch_export" and the zip's first member is MANIFEST.txt — that is the one-glance check, on any device. #} {% block body %}

Export {{ est.counts.events }} events · {{ est.counts.entries }} reflections & check-ins · {{ est.photo_files }} photos{% if est.voice_files %} · {{ est.voice_files }} voice memos{% endif %}

⬇ Everything

One zip: the full database as JSON, the five CSVs, and every photo and voice memo. The JSON is the copy you restore from — the CSVs are views generated from it.

⬇ latch-bundle.zip ≈ {{ est.total_bytes|filesizeformat }}

⬇ data only, no media ≈ {{ est.db_bytes|filesizeformat }}

media
{{ est.photo_files }} photo{{ 's' if est.photo_files != 1 }}{% if est.voice_files %} · {{ est.voice_files }} voice memo{{ 's' if est.voice_files != 1 }}{% endif %} · {{ est.media_bytes|filesizeformat }}
data
{{ est.db_bytes|filesizeformat }} on disk
timezone
{{ tz }}
{% if est.media_missing %}

⚠️ {{ est.media_missing }} media row{{ 's' if est.media_missing != 1 }} ha{{ 've' if est.media_missing != 1 else 's' }} no file on disk. They are skipped and named in the archive's MANIFEST.txt rather than failing the whole export.

{% endif %}

The zip is streamed, so the browser cannot show a progress bar for it — that is expected, not a stall. Sizes here are measured, not guessed.

Single files

⬇ latch.json Every table, verbatim. The restore-shaped copy — the only authoritative one.
⬇ daily.csv One row per day, gaps filled with zeros. The one to open in Numbers.
⬇ events.csv One row per event, undone ones kept and marked. The whole log, flat.
⬇ sessions.csv One row per lock, with the timer recomputed: added, frozen, elapsed.
⬇ entries.csv Reflections and check-ins, with the five scores in their own columns.
⬇ assignments.csv Every training assignment: assigned, due, verified or failed.
⬇ kit.csv One row per thing you own, with its specs and its derived numbers — bouts, hours, cost per bout. An empty cell was never measured.
⬇ bouts.csv Every bout of play or wear: what was on, how long, whether you were caged, how it ended, and the answers you gave.

CSVs carry a UTF-8 BOM so Excel does not mangle accented text — in Python, open them with encoding='utf-8-sig'. Each timestamp appears twice: the epoch column is the record, the *_local column is the one a spreadsheet reads as a date. An empty score cell means no check-in was taken; it does not mean zero.

Before you carry it out of here

This file is an unencrypted copy of every reflection and check-in. It never leaves this box unless you carry it. Nothing here is shareable — the share layer (P6) uses a separate, redacted export.

{{ app_name }} v{{ version }} · generated on request, nothing is stored · zero outbound calls.

{% endblock %}