Format version 1 · this page last updated 8 September 2026
Every technical evaluator of a journal app asks the same question: what happens to my writing when the developer stops? This page is the answer. It describes the export completely enough that someone who has never seen Moraine's code could write a program to read it — and the export ships with one, a page that shows the whole library in any browser.
Two promises sit behind this page. Export is never gated: it works
during the trial, after it, bought or not, and in the code that rule is a test named
ExportIsNeverGatedTests, which expires the trial and then asserts that an
export still writes every entry and every embedding. And the format is
versioned conservatively: the number moves only for a change an older build
could not read, and the app refuses an archive newer than it understands rather than
guessing. Everything added since version 1 was added as an optional key, so an export
taken on the first day still restores today, and today's restores into a build from
that day, minus what that build had never heard of.
Export everything writes a folder named after the day:
Moraine Export 2026-09-08/
moraine.json every entry and every journal, as readable JSON
index.html the standalone reader — open it in any browser
media/ photographs, recordings, video and sketches as ordinary files
Media is kept as files rather than base64 inside the JSON, because a recording
that can only be played by the app that wrote it is not much of a backup. A file in
media/ opens in whatever opens that kind of file. The manifest refers to
each one by its file name, relative to media/.
The manifest has had two other names. An export written when the app was called
Tarn has tarn.json; one written when it was called Rambling has
rambling.json. The contents are identical, the app reads all three, and
a reader of your own should too — an archive written under the first name is no less
somebody's library than one written yesterday.
moraine.json is one JSON object. Keys are sorted and the file is
pretty-printed, on purpose: the point of a plain-JSON archive is that a person can
open it and see their own words. Dates everywhere are ISO 8601 in UTC, with seconds
(2019-06-14T09:30:00Z).
| Key | Type | Meaning |
|---|---|---|
formatVersion | integer | Currently 1. A reader should refuse anything higher. |
exportedAt | date | When the export was written. |
appVersion | string | The app's marketing version, e.g. 1.2. |
itemCount | integer | The length of items. Redundant, and a cheap check that the file is whole. |
items | array of entry | Every entry, oldest first. Described below. |
journals | array of journal, optional | The journals themselves, so a restore brings back the rooms and not only which entries were in them. Absent from archives written before journals carried a look of their own; an entry still names its journal, so a reader without this key loses nothing but colour. |
| Key | Type | Meaning |
|---|---|---|
name | string | The journal's name. Entries refer to journals by name, not by id, so an archive restored into a library that already has a "Work" joins it rather than making a second one. |
symbolName | string | An SF Symbols name, e.g. book.closed. |
tintRaw | string | The accent: one of coral, sage, tide, dusk, clay, slate. |
themeRaw | string, optional | The theme the journal wears — one of drift, foolscap, meridian, harbour, understory, vesper, ledger, kiln, heath — or absent for the app's own look. |
Every entry is one object in items. A key marked optional is
simply absent when it has nothing to say, and a reader should treat an absent key as
null, false or an empty list as the type suggests. A key a
reader does not recognise should be ignored: that is how every field below that was
added after version 1 arrived without the version moving.
| Key | Type | Meaning |
|---|---|---|
id | UUID string | Stable for the life of the entry, across every device and every export. Restoring an archive skips any id already in the library, which is why restoring the same archive twice changes nothing. An entry imported from Day One keeps Day One's own identifier, reformatted as a UUID; one from Apple Journal has a UUID derived from its file name. |
createdAt | date | When the entry is dated — the capture moment, or the date the person corrected it to. |
modifiedAt | date | Last edit. |
kind | string | What the entry is: note, journal, link, audio (a voice recording), idea, image (an entry that is a picture), sketch (one that is a drawing). |
status | string | Where the entry is in the on-device pipeline: captured (raw, waiting), enriching, enriched (titled, summarised, tagged, embedded) or failed (gave up; still browsable). A reader of your own can ignore this. |
source | string | The door it came through: app, shareExtension, safariExtension, intent (Siri or a Shortcut), drop (dragged onto the Mac), imported (Day One or Apple Journal). A note with source imported is an import's receipt — the record of what came across and what did not. |
journalName | string, optional | The journal it belongs to, by name. Absent means the default journal. |
| Key | Type | Meaning |
|---|---|---|
title | string, optional | Generated on the device, or typed by the person. |
titleUserEdited | boolean | The person wrote or edited the title. The app never overwrites it again. |
text | string, optional | The entry itself, in Markdown. Headings, lists, quotes, code blocks, emphasis and links are what the app renders; anything else is shown as written. |
url | string, optional | For a link: the address. |
articleText | string, optional | For a link: the readable text of the page, saved at capture. |
transcript | string, optional | For a recording: the on-device transcript. |
summary | string, optional | One or two sentences, generated on the device. |
tags | array of string | Display names, in the entry's order. Empty when there are none. |
tagsUserEdited | boolean, optional | The person curated the tags; absent in older archives and read as false. |
isFavorite | boolean | Starred. |
| Key | Type | Meaning |
|---|---|---|
latitude, longitude | number, optional | WGS 84 degrees. |
placeName | string, optional | "Norwood, Adelaide" — reverse-geocoded, chosen from a search, or carried across from an import. |
weatherSummary | string, optional | "18° Partly Cloudy", as a single string. |
weatherFromWeatherKit | boolean, optional | Whether the weather came from Apple Weather rather than from an import. It decides only whether the entry carries Apple's attribution; absent reads as unknown, which claims nothing. |
deviceName | string, optional | The device it was captured on. |
All optional, all generated, all ignorable by a reader of your own. They are carried so that a restore is a restore rather than a re-run.
| Key | Type | Meaning |
|---|---|---|
detectedEventDate, detectedEventTitle, detectedEventHasTime | date, string, boolean | A plan the app spotted in the words. Only ever a suggestion. |
calendarEventIdentifier | string, optional | Set once the person handed that plan to Apple Calendar. |
planDismissed | boolean | The person said no; never suggest it again. |
mood | string, optional | How the entry reads, if the person switched that on: joyful, excited, grateful, proud, happy, content, calm, hopeful, relieved, neutral, surprised, annoyed, anxious, sad, frustrated, tired, lonely, angry, overwhelmed, miserable, scared. |
moodUserSet | boolean, optional | The person set or corrected it themselves. |
mediaTitle, mediaSubtitle, mediaSynopsis, mediaLinkURL, mediaArtworkURL | string, optional | A film, book, show, podcast, album or app the entry names, resolved to a link if the person switched that on. |
mediaKindRaw | string, optional | One of movie, tvShow, book, podcast, music, app. |
mediaDismissed | boolean | The person removed the link; never look this entry up again. |
mediaUserSet | boolean, optional | The person chose the work themselves after the lookup got it wrong. |
Each of these names a file in media/. The names the app writes are
the entry's id plus a suffix, so a folder listing reads back to its entries without
the manifest.
| Key | Type | Meaning |
|---|---|---|
audioFile | string, optional | The voice recording: <id>.m4a. One per entry. |
imageFile | string, optional | The single image a share-sheet or Safari clip arrived with: <id>.png or .jpg, or .data when the bytes are neither. |
photos | array, optional | Attached photographs, in order. Each is { "file": "<id>-photo-0.jpg", "takenAt": date }; file is required and takenAt — when the camera says it was taken — is optional. Photographs are stored re-encoded to at most 2048 px on the long edge. |
videos | array, optional | Attached video, in order, in its original container: { "file": "<id>-video-0.mov", "takenAt": date, "duration": seconds }, the last two optional. Video is never re-encoded. |
drawings | array, optional | Sketches, in order: { "file": "<id>-drawing-0.png", "strokeFile": "<id>-drawing-0.strokes" }. The PNG is the part that means anything outside Moraine and is required; strokeFile is PencilKit's own stroke data, which keeps the sketch editable in the app, and is optional. |
photoFiles | array of string, optional | The shape photos had in the first release, before capture dates were kept. Read, never written; a reader should accept either. |
The objects inside photos, videos and drawings
share three keys and add one each:
| Key | On | Type | Meaning |
|---|---|---|---|
file | all three | string | The file name in media/. Required. |
takenAt | photos, videos | date, optional | When the camera took it, read from the original at the moment it was attached. |
duration | videos | number, optional | Seconds. Carried so that a reader need not decode the file to find out. |
strokeFile | drawings | string, optional | PencilKit's stroke data, with a .strokes extension. Nothing but Moraine reads it; the PNG beside it opens anywhere. |
| Key | Type | Meaning |
|---|---|---|
embedding | string, optional | The entry's sentence embedding, so a restored library can search by meaning immediately rather than after every entry is reprocessed: base64 of an array of little-endian 32-bit floats, unit-normalised. This is the one field on the page that is for the machine rather than the person, and a reader of your own can ignore it. |
embeddingVersion | integer | Which model produced it. 1 is Apple's on-device NLContextualEmbedding for English, 512 dimensions. 0 means there is none. |
An export of a library with one entry in it — a journal entry with a photograph, two tags, a place and the weather — exactly as the app wrote it. The embedding is shortened to eight floats here; a real one is 512.
Moraine Export 2026-09-08/
index.html
moraine.json
media/
6B1F2C4E-9A3D-4E2B-8C7A-1D5E6F7A8B9C-photo-0.png
{
"appVersion" : "1.1",
"exportedAt" : "2026-09-08T10:28:58Z",
"formatVersion" : 1,
"itemCount" : 1,
"items" : [
{
"createdAt" : "2019-06-14T09:30:00Z",
"detectedEventHasTime" : false,
"deviceName" : "Rashid's iPhone",
"embedding" : "AAAAAM3MzD3NzEw+mpmZPs3MzD4AAAA\/mpkZPzMzMz8=",
"embeddingVersion" : 1,
"id" : "6B1F2C4E-9A3D-4E2B-8C7A-1D5E6F7A8B9C",
"isFavorite" : true,
"journalName" : "Travel",
"kind" : "journal",
"latitude" : -34.9805,
"longitude" : 138.5119,
"mediaDismissed" : false,
"modifiedAt" : "2019-06-14T09:30:00Z",
"photos" : [
{
"file" : "6B1F2C4E-9A3D-4E2B-8C7A-1D5E6F7A8B9C-photo-0.png",
"takenAt" : "2019-06-14T09:31:00Z"
}
],
"placeName" : "Glenelg",
"planDismissed" : false,
"source" : "app",
"status" : "enriched",
"summary" : "A walk, and a decision deferred.",
"tags" : [
"houses",
"walking"
],
"tagsUserEdited" : false,
"text" : "We walked the **long** way round and talked about Glenelg.\n\nNothing was decided.",
"title" : "The argument about moving house",
"titleUserEdited" : false,
"weatherFromWeatherKit" : true,
"weatherSummary" : "18° Partly Cloudy"
}
],
"journals" : [
{
"name" : "Travel",
"symbolName" : "book.closed",
"tintRaw" : "coral"
}
]
}
Note what is absent rather than null — url, transcript,
mood, themeRaw — and that the JSON encoder escapes the
slash in the base64. Both are ordinary JSON and any parser handles them.
index.html is written by the export, from the same manifest, every
time. Open it in any browser and the whole library is there: entries under month
headings with a contents by year, dates, places, weather, tags, photographs inline,
video and recordings with a player, transcripts and saved article text folded under a
heading. It contains no script, and fetches no stylesheet, font or image from
anywhere: everything it shows is read from media/ beside it, and the one
address on the page is a link to this document. Move the folder whole and it works;
move the page alone and the words still read, with a caption where each picture was.
It is generated rather than maintained. A reader kept beside a format drifts from
it, and a reader that drifts is worse than none — so in the code, the test that
guards it decodes moraine.json back off disk and asserts that regenerating
the page from that produces the file byte for byte.
Photographs are referenced, not inlined. That was measured rather than assumed: base64 costs a third again over the bytes, so a library carrying 1.5 GB of photographs would be a 2 GB HTML file, which no browser opens well. The folder is the artifact.
Export encrypted… writes the same folder as one file with the
extension .morainebackup, sealed with a password. There is no recovery
and no copy of the password anywhere: an archive Moraine could open on your behalf is
one somebody else could open with your device.
The file is two layers. The inner one packs the folder into a single stream; the
outer one encrypts that stream in chunks and begins with the eight bytes
MORAENC1. Both are small enough to describe fully.
Files, one after another, to the end of the stream. No index, no compression — what it packs is JSON, which is small, plus photographs, audio and video, which are already compressed. All integers are big-endian.
repeated to end of stream:
pathLength UInt32
path UTF-8, relative to the folder root (e.g. "moraine.json", "media/….png")
dataLength UInt64
data the file's bytes
A reader must refuse any path that would climb above the folder it is unpacking into. An archive is a file from somewhere else, and a restore that writes wherever the file asks is how a backup becomes an attack.
magic 8 bytes "MORAENC1"
salt 16 bytes random
iterations UInt32 big-endian; 210,000 as written today
repeated to end of file:
length UInt32 big-endian, of the sealed chunk that follows
sealed AES-256-GCM, combined form: 12-byte nonce ‖ ciphertext ‖ 16-byte tag
The key is PBKDF2-HMAC-SHA256 over the password's UTF-8 bytes with the salt and iteration count from the header, 32 bytes out. The iteration count is stored in the file so that raising it later does not orphan existing archives. The plaintext is the packed stream above, cut into 4 MB chunks, each sealed separately so that memory stays flat however large the library is. Each chunk's index — a big-endian UInt64 starting at 0 — is passed as the additional authenticated data, so a file whose chunks have been reordered, duplicated or truncated fails to open rather than restoring a plausible-looking subset of somebody's life.
An archive sealed when the app was called Tarn begins TARNENC1. The
bytes after the magic are identical and the app opens both; a reader of your own
should accept either. A wrong password and a damaged file give the same error on
purpose: telling somebody which it was tells an attacker the same thing.
| Version | What it means |
|---|---|
| 1 | The format as first shipped, August 2026, and still the current number. Added since, all as optional keys so the version did not move: photos as objects with capture dates (replacing photoFiles), videos, drawings, tagsUserEdited, mood and moodUserSet, mediaUserSet, weatherFromWeatherKit, journals on the manifest, and index.html beside it. |
The number will move when a change lands that an older build could not read correctly — a key whose absence could no longer be treated as "nothing to say", or a change to the meaning of one already there. When it does, this page will say what changed and how to read both.
The rules, gathered in one place, for anyone writing a reader:
moraine.json, then rambling.json, then tarn.json. Refuse a formatVersion higher than the one you know.photos or photoFiles; prefer photos when both are present.media/, and expect that a file may be missing: the manifest describes what was exported, and folders get copied badly.text as Markdown, or show it as written — the words are the entry; the formatting is a courtesy.embedding, status and the detected-plan fields unless you are rebuilding the app.If this page and the app ever disagree, that is a bug in one of them and worth an email. In the code, a test holds the page to the archive: every field the app writes, and every value an entry can carry, must be named here, so the two cannot drift without a build going red.