Rename a .tmln file to .zip and open it. Inside are three things:
manifest.json— what this package is: format, schema version, which build wrote it, when, how many trees and memories, how many bytes of media, which media mode, and a SHA-256 of the data file.data.json— every timeline, memory, media record, tag, person and measurement, as readable text.media/— the actual files. Photos, videos, voice notes, scans.
That is the whole format. It is deliberately boring, and the two things worth explaining about it are both consequences of that last folder.
Why the bytes are packed first
The exporter writes media/ before it writes data.json, and the order is load-bearing.
A re-encoded photo is a different file. New size, new dimensions, new hash. If data.json were written from the database — describing the originals — and the package then contained shrunk copies, every single file would fail its integrity check on the way back in, and the importer would be right to throw them all away. So the media is packed first and the data file is written to describe what is actually in the package, not what was in the database when the export started.
This is the kind of bug that never shows up in a test with one photo in it, and always shows up on the day someone restores a real grove.
Three ways to pack the media
The export dialog offers three modes, and the middle one has a caveat we say out loud:
- Original files — every byte, exactly as stored. The only mode that restores completely.
- Smaller photos — photos re-encoded to 1280 pixels on the long edge, which takes a 4 MB original down to a couple of hundred kilobytes. Videos and voice notes stay behind, because a thumbnail of a video is a still and a thumbnail of audio is nothing at all.
- No attachments — the same content the JSON export carries, in a package.
In the two lossy modes the media rows stay in data.json even though their bytes are absent. Losing the row as well would lose the fact that the photo ever existed — and the importer counts those separately so it can tell you the difference.
Coming back in
The importer validates the entire package before it mutates anything. A corrupt file never half-lands: you either get the import or you get an error, and never a grove in a state nobody designed.
After that it upserts by id, and the copy with the newer updatedAt wins. The copy it declined is not discarded — it goes into Import review, where you can look at what the merge decided and disagree with it. Media bytes are verified against the hash recorded in data.json before being written, and identical bytes already on disk are pointed at rather than duplicated.
Four kinds of failure, counted apart
When something goes wrong the result screen counts four failures separately, and the distinction is the whole point:
- Missing — the file was listed but its bytes were not in the package. You exported JSON-only, or a lossy mode.
- Corrupt — the bytes were there but did not match their hash.
- Unwritable — the bytes were there and intact, and this iPhone had nowhere to put them. A full disk.
- Reused — the bytes were already here, so nothing was copied.
“Your file did not contain these” and “this phone had nowhere to put them” need opposite advice. One “12 files failed” would have been easier to build and useless at the moment it appeared.
The JSON export, and what it deliberately is not
.tmln‘s smaller sibling is a plain JSON file, and the difference between them is exactly one thing: the bytes. JSON carries every memory, tag, person, place and measurement as readable text — including the private ones, because this is your own data leaving rather than a keepsake you might hand to somebody, and it has never carried a watermark in any tier. Attachments appear as entries with a name, a type and a size; their files are not inside. Sealed capsules travel still sealed.
It is the format to reach for if you want to read your grove in a text editor, run a script over it, or check what the app thinks it knows about you. It is not the format to restore from, and the app says which is which rather than letting you find out on a new phone.
Schema versions, and refusing rather than guessing
Both files carry a schemaVersion, currently 1. A build reads the version before it reads anything else, and if the number is higher than the one it understands it stops with a sentence that names the situation: this file was made with a newer version of Timegrove.
That is a deliberate refusal. The alternative — decode what you recognise, drop what you do not — is how an import silently loses a field that a later version added, and the person only discovers it years afterwards, if ever. Within a version the decoder is generous in the other direction: only schemaVersion, timelines and events are required, and anything absent decodes as empty. A perfectly valid file with no media, or no tags, has to import, and for a while it did not — Swift’s synthesised decoder demanded every key regardless of its default, and a hand-written one was the fix.
If you want it locked
Leave the passphrase field empty and you get a plain .tmln. Set one and the whole package is encrypted with AES-256-GCM before it leaves the app, and the extension becomes .tmlnx. Nothing in it is readable without the passphrase — not the tree names, not the photo count, not the manifest.
And nothing can recover it if you forget it. There is no account to reset it against, which is the same architecture that means there is no server holding your grove, seen from the angle where it costs you something. The dialog says this in bold before you commit, because it is the one screen in the app where a cheerful tone would be a lie.
A keepsake is not a backup
The PDF book and the HTML story are lovely and they are one-way. Nothing inside either can be turned back into a tree. Try to import one and the app says so in a sentence rather than a stack trace: a PDF book is a keepsake to read, not a backup — there is nothing in it to bring back.
We could have quietly failed. Naming the category difference is more useful than a generic “unsupported file”, because the person doing it is usually mid-migration and does not need a puzzle.
Why the exit is free
JSON and .tmln are free in both tiers, unlimited, and never watermarked. The paid formats are the keepsakes — the PDF book and the interactive HTML page — where the free tier gets one a month with a small footer every tenth page.
The line is not arbitrary. A format that gets your data out is not a feature; it is the exit. Charging for it, rate-limiting it, or watermarking it converts a memory app into a hostage situation with a subscription attached, and every argument for doing it is an argument that your grove is worth more to us if you cannot take it somewhere else. A book we designed and rendered is a thing we made. Your fourteen years of photographs are not.
The practical version of the same principle: if this app is discontinued, you can still read your data with a text editor and a zip utility, on any machine, forever. That is what an open format is for, and it is why the privacy page and the format specification say the same thing in two different registers. More on that under Open format.