Moraine

The export format

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.

The folder

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.

The manifest

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).

KeyTypeMeaning
formatVersionintegerCurrently 1. A reader should refuse anything higher.
exportedAtdateWhen the export was written.
appVersionstringThe app's marketing version, e.g. 1.2.
itemCountintegerThe length of items. Redundant, and a cheap check that the file is whole.
itemsarray of entryEvery entry, oldest first. Described below.
journalsarray of journal, optionalThe 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.

A journal

KeyTypeMeaning
namestringThe 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.
symbolNamestringAn SF Symbols name, e.g. book.closed.
tintRawstringThe accent: one of coral, sage, tide, dusk, clay, slate.
themeRawstring, optionalThe theme the journal wears — one of drift, foolscap, meridian, harbour, understory, vesper, ledger, kiln, heath — or absent for the app's own look.

An entry

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.

Identity and time

KeyTypeMeaning
idUUID stringStable 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.
createdAtdateWhen the entry is dated — the capture moment, or the date the person corrected it to.
modifiedAtdateLast edit.
kindstringWhat the entry is: note, journal, link, audio (a voice recording), idea, image (an entry that is a picture), sketch (one that is a drawing).
statusstringWhere 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.
sourcestringThe 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.
journalNamestring, optionalThe journal it belongs to, by name. Absent means the default journal.

Words

KeyTypeMeaning
titlestring, optionalGenerated on the device, or typed by the person.
titleUserEditedbooleanThe person wrote or edited the title. The app never overwrites it again.
textstring, optionalThe entry itself, in Markdown. Headings, lists, quotes, code blocks, emphasis and links are what the app renders; anything else is shown as written.
urlstring, optionalFor a link: the address.
articleTextstring, optionalFor a link: the readable text of the page, saved at capture.
transcriptstring, optionalFor a recording: the on-device transcript.
summarystring, optionalOne or two sentences, generated on the device.
tagsarray of stringDisplay names, in the entry's order. Empty when there are none.
tagsUserEditedboolean, optionalThe person curated the tags; absent in older archives and read as false.
isFavoritebooleanStarred.

Where and when it was written

KeyTypeMeaning
latitude, longitudenumber, optionalWGS 84 degrees.
placeNamestring, optional"Norwood, Adelaide" — reverse-geocoded, chosen from a search, or carried across from an import.
weatherSummarystring, optional"18° Partly Cloudy", as a single string.
weatherFromWeatherKitboolean, optionalWhether 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.
deviceNamestring, optionalThe device it was captured on.

Things the app noticed

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.

KeyTypeMeaning
detectedEventDate, detectedEventTitle, detectedEventHasTimedate, string, booleanA plan the app spotted in the words. Only ever a suggestion.
calendarEventIdentifierstring, optionalSet once the person handed that plan to Apple Calendar.
planDismissedbooleanThe person said no; never suggest it again.
moodstring, optionalHow 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.
moodUserSetboolean, optionalThe person set or corrected it themselves.
mediaTitle, mediaSubtitle, mediaSynopsis, mediaLinkURL, mediaArtworkURLstring, optionalA film, book, show, podcast, album or app the entry names, resolved to a link if the person switched that on.
mediaKindRawstring, optionalOne of movie, tvShow, book, podcast, music, app.
mediaDismissedbooleanThe person removed the link; never look this entry up again.
mediaUserSetboolean, optionalThe person chose the work themselves after the lookup got it wrong.

Media

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.

KeyTypeMeaning
audioFilestring, optionalThe voice recording: <id>.m4a. One per entry.
imageFilestring, optionalThe single image a share-sheet or Safari clip arrived with: <id>.png or .jpg, or .data when the bytes are neither.
photosarray, optionalAttached 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.
videosarray, optionalAttached 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.
drawingsarray, optionalSketches, 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.
photoFilesarray of string, optionalThe 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:

KeyOnTypeMeaning
fileall threestringThe file name in media/. Required.
takenAtphotos, videosdate, optionalWhen the camera took it, read from the original at the moment it was attached.
durationvideosnumber, optionalSeconds. Carried so that a reader need not decode the file to find out.
strokeFiledrawingsstring, optionalPencilKit's stroke data, with a .strokes extension. Nothing but Moraine reads it; the PNG beside it opens anywhere.

The index

KeyTypeMeaning
embeddingstring, optionalThe 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.
embeddingVersionintegerWhich model produced it. 1 is Apple's on-device NLContextualEmbedding for English, 512 dimensions. 0 means there is none.

A worked example

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.

The standalone reader

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.

The sealed archive

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.

Packing

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.

Sealing

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 history

VersionWhat it means
1The 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.

Reading it yourself

The rules, gathered in one place, for anyone writing a reader:

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.