Inchgrove bundle format

Version 1 — the interchange format used by Back Up Everything, Quick Backup, cloud sync, and import.

A bundle is a single JSON document. A .zip archive is the same document (bundle.json) plus the photo files it references. You only need this page if you are building a bundle by hand or exporting from another tool — the app writes and reads it for you.

Import is a merge, never a replace. Every record carries a version, and the higher version wins. Importing the same file twice is safe, and newer local edits are never overwritten by older data.

Shape

{
  "version": 1,
  "featureLevel": 19,
  "exportedAt": "2026-07-16T09:30:00.000Z",
  "coreSpecies": [...],
  "coreSpeciesNames": {...},
  "species": [...],
  "trees": [...],
  "treeGroups": [...],
  "pots": [...],
  "diary": [...],
  "jobs": [...],
  "collection": {...},
  "collectionJobs": [...],
  "collectionDiary": [...],
  "treePhotos": [...],
  "potPhotos": [...],
  "speciesPhotos": [...],
  "collectionPhotos": [...],
  "customFieldDefs": [...],
  "trunkMeasurements": [...],
  "careGuideSections": [...],
  "soilMixes": [...],
  "fertilisers": [...],
  "pestTreatments": [...],
  "tombstones": [...]
}

version must be 1; any other value is rejected at parse time. Every section is optional — a bundle carrying only careGuideSections is exactly what the care guide pack is.

featureLevel is the second, non-breaking version number: it goes up whenever a section or a field joins the format, while version stays at 1. It is optional and may be omitted. Readers use it to decide what they may write, never whether they may read — an older build merges a newer bundle safely, so a file is never rejected for being ahead. What it does stop is that older build pushing its own export back over a newer one in cloud sync, where it would strip the sections and fields it cannot see. Sections an app does not recognise are otherwise carried through an import and written back out unchanged, so passing a bundle through an older build does not quietly shed them.

A care guide pack additionally carries bundleType: "careGuides" and an attribution string naming whoever the sharer chose to credit ("" when they shared unattributed). It is self-declared and unverifiable — provenance the recipient can weigh, not proof of authorship. Importing a pack marks each section it brings in as imported and stamps that attribution onto it, so text from someone else's file never reads as the recipient's own writing. Only the careGuides bundle type does this: a full backup is the user's own data, and restoring one must not relabel it. A pack carries only sections the sharer wrote, never ones they imported themselves.

Care guide sections are the one place a merge can lose your writing. They merge on a deterministic ID, so an incoming section at a higher version replaces yours — and because the import stamps its version onto your record, a backup taken beforehand is then stale and re-importing it will not bring your text back. The app offers to hold back sections you already have when the file was written by someone else; it leaves that off for your own backups and for cloud sync, which must keep converging normally across your devices.

Sections

Section Max records Notes
coreSpecies — Included in file exports for reference only — the catalogue ships with the app, so import ignores this section entirely and there is no need to write it. (Cloud sync omits it.) To override a built-in species, put a record in species carrying that species' id and "isCore": true; it wins over the built-in entry.
coreSpeciesNames — Language-keyed common-name patches for core species, e.g. { "<id>": { "fr": ["Chêne pédonculé"] } }. Merged into override records on import; other species fields are untouched.
species 1000 Custom species. Identity is genus|species|cultivar, case-insensitive. A record with "isCore": true overrides the built-in species of the same id — wholesale, so it must repeat every field it wants to keep (see below). otherNames lists botanical synonyms (e.g. "Azalea indica"), searched along with the name. limeHating marks a species that needs lime-free soil: repotting it into a soil mix not marked limeFree draws a warning.
trees 500 speciesId is required. potId names the pot the tree stands in ("" for none). An ID that names no pot is cleared on import. purchasePrice and value are plain numbers in the collection's currency, rounded to cents, null for none; valuedAt is the YYYY-MM-DD date of the value, dropped when there is no value. potHistory lists the pots the tree has moved into, oldest first, as {"potId", "from"}: from is the YYYY-MM-DD it moved in ("" when not known), and potId is "" for a spell out of any pot. The app appends to it when the tree changes pot; an ID in it may name a pot since deleted, and is kept. shelter is where the tree is spending the winter: "Under cover", "Frost-free", or "" for the open; any other value reads as the open. shelterSince is the YYYY-MM-DD the tree went into that shelter, set by the app whenever the shelter changes; "" in the open or when not known.
treeGroups 200 Named selections of trees. Membership is inline (treeIds, an array of tree IDs), deduplicated and sorted so two devices that added the same trees in a different order store the same array. An ID that names no tree is kept and simply resolves to nothing — a group is never dropped because a member is missing.
pots 500 quantity is how many identical pots the record stands for (a whole number, 1 when absent). A pot does not name its trees: each tree names its pot, so a set can hold as many trees as it has pots. purchasePrice, value and valuedAt are as on a tree, and are per pot: a set's total is the figure times quantity.
diary 5000 Tree diary entries.
jobs 5000 Tree jobs.
collection 1 A single object, not an array — the collection is a singleton. currencyCode is the ISO 4217 code every tree's and pot's figures are in ("" when unset).
collectionJobs 5000
collectionDiary 5000
treePhotos 2500
potPhotos 1000
speciesPhotos 1500
collectionPhotos 1000
customFieldDefs — See below.
trunkMeasurements 5000 A dated diameterMm (trunk) and/or heightMm (tree height), both whole millimetres; either may be null, not both. updatedAt (UTC, null on older records) settles two edits of one reading at the same version, newest first.
careGuideSections 2000 One row per (genus, section). IDs are deterministic: careGuide:<genus-lowercase>:<section>. Records whose ID doesn't match their genus and section are rejected. imported and attribution record that the text arrived in someone else's pack; both are ordinary record content and travel with the section. So does updatedAt, and importing a pack does not rewrite it — that is what lets a section you have since rewritten yourself win against an older copy of it.
soilMixes 200 Substrate recipes belonging to the collection. Ingredients are inline ([{ "label", "percent" }]), whole percent, totalling no more than 100 — a total below 100 leaves the remainder unspecified. Labels must be unique within a mix, case-insensitively. limeFree marks a mix suited to lime-hating species.
fertilisers 200 Feeds belonging to the collection, the soil mix's sibling. The N-P-K analysis is inline ({ "n", "p", "k" }), each figure a number from 0 to 100 rounded to one decimal, or null where it was not recorded; the whole field is null when none of the three is known.
pestTreatments 200 Sprays and drenches belonging to the collection. Name and notes only — what a treatment is for, at what dilution, is prose.
tombstones 20000 Deletions. See below.

Record fields

The fields the notes above don't cover, for anyone writing records by hand. Every record has an id (a UUID) and the version and updatedAt described below. An omitted field takes its default. Dates are YYYY-MM-DD. Date-times are local time with no zone (2025-04-12T08:30:00), like a photo's timestamp. Choice values such as a style or a pot material are stored in English, spelled as the app's pickers spell them; any other text is kept as a value of its own.

Shared by trees, pots, species and the collection

  • customFields: values keyed by definition id (see custom field definitions).
  • heroPhotoId: the id of a photo in the record's own photo section to show as its main photo, or null for the most recent. Import clears an id that names no photo.
  • flagged (not on the collection): true or false, a marker the app attaches no meaning to.

Trees and pots

  • tag: a whole number of 1 or more, unique among trees (or among pots), or null. name: text. acquired: a date or null.
  • archivedAt: the date the record left the collection, or null. A date is what archives a record. archiveReason: "Died", "Given away" or "Sold" for a tree; "Broken", "Given away" or "Sold" for a pot; or any other text. archiveNote: up to 1000 characters.

Trees

  • style, size, stage: choice values.
  • description, developmentHistory: up to 4000 characters each.
  • plantedOut: true for a tree in the ground. The default, false, means it is in a container, which costs it two zones of hardiness.
  • containerVolume: the nursery container in litres, for a tree with no pot record: "<1", "1", "2", "3", "4", "5", "7", "10", "15", "20", "25", "30", "40", "50", "60" or ">60", as strings. Anything else reads as "", not recorded.

Pots

  • material, colour, texture, shape, size (the tree size the pot suits), condition: choice values. maker: text. glazed: true or false.
  • lengthMm (the longest side), widthMm, depthMm: outside measurements in whole millimetres, up to 2000, or null.
  • notes: up to 4000 characters.

Species

  • genus, species, cultivar, family: text. generalType: a choice value.
  • commonNames: lists keyed by language, { "en": ["Japanese maple"], "fr": ["Érable du Japon"] }.
  • leafFall: "Deciduous", "Evergreen", "Semi-evergreen" or "". hardiness: the USDA zone as a string, "1" to "13", or "".

Collection

  • name: text. started: a date or null. description, developmentHistory: up to 4000 characters each.
  • hardinessZone: the site's USDA zone, "1" to "13". country: an ISO 3166-1 alpha-2 code ("GB"). climateZone: a Köppen prefix the app knows ("Cfb"), exactly cased. hemisphere: "north" or "south", needed only where the country doesn't settle it. seasonalOffset: how many half-months the seasons run early (positive) or late (negative), −6 to 6. Each is "" (or 0) when unset.
  • ownerId: an id the app mints once, so a bundle can be told apart from one written by someone else's collection. Leave it out of a bundle you write by hand.

Jobs (jobs and collectionJobs)

  • treeId: the owning tree's id. A collection job keeps the same field name, holding the collection's fixed id, c0111ec7-0000-4000-8000-000000000001.
  • text (required); dueDate, a date (required); completedAt, a date-time, or null while the job is open.
  • recurrence: null, or { "intervalYears": 1, "months": [3, 9] } — due in those months (1–12) every 1, 2 or 3 years. seriesId ties the occurrences of one repeating job together.
  • A planned recipe and the record kept at completion, one pair per kind: soilMixId/soilMixSnapshot, fertiliserId/fertiliserSnapshot, pestTreatmentId/pestTreatmentSnapshot. An id may name a recipe since deleted. A snapshot is the recipe's name and notes, plus its ingredients or npk.

Diary (diary and collectionDiary)

  • treeId: the owner, as on a job. text: the entry. createdAt: the date-time it is dated by.

Record versioning

Each record has a version field (integer, starting at 1). On import:

  • New record → added with its version (minimum 1).
  • Import version < local → skipped as stale.
  • Import version > local → local updated with the import version.
  • Import version = local, data identical → nothing happens.
  • Import version = local, data differs → a concurrent edit. The newer updatedAt wins and the version is left alone; with no usable timestamp on either side, a stable ordering of the two records' content decides. The rule is a pure, symmetric function of the two copies, so both devices pick the same winner and converge rather than re-pushing opposite bundles forever.

Timestamps used for that tie-break are compared as UTC instants, not as strings. Write updatedAt as a UTC ISO string (2026-07-16T09:30:00.000Z); a zone-less stamp is read as UTC.

Every record type carries updatedAt, the collection included. It is optional: omit it and the record simply has no timestamp, which costs nothing until it meets a same-version disagreement — at which point that record loses to any copy that does have one, and two copies with neither fall back to content ordering. Records written before a given field existed legitimately have none.

Deletions (tombstones)

A deletion travels as a tombstone whose version is the deleted record's version + 1.

  • An incoming tombstone deletes a record only when tombstone.version > record.version — a record edited after the deletion survives, and the stale tombstone is discarded. This applies to every section, photos included.
  • An incoming record resurrects through a local tombstone when record.version >= tombstone.version, which also removes the tombstone.
  • Those two are mirror images of one rule: an edit beats a delete on a tie. A delete and a concurrent edit of the same base record both produce version + 1, so a tie is always an edit-after-delete, never an untouched copy.
  • A species is never tombstone-deleted while a surviving local tree still references it.

Overriding a built-in species

An override replaces the built-in entry rather than patching it. Any field the override omits is a field the user loses — most easily missed are hardiness and the commonNames languages the catalogue ships and your record does not. Copy the built-in record, change what you mean to change, and leave the rest.

Because the catalogue grows between releases, an override written against an old version can quietly start dropping fields that were added since. If all you want is to add a common name to a built-in species, use coreSpeciesNames instead — it merges into the existing names and touches nothing else.

Custom field definitions

Each definition has an id (UUID), an entityType ("tree", "pot", "species", or "collection"), labelTranslations, a type ("string", "number", "currency", "date"), an entryType ("free" or "fixed"), and options (an array, required when entryType is "fixed").

Definitions carry a version and updatedAt like any other record, and merge by the same rules — a definition edited on one device is not overwritten by a stale copy from another. A definition omitting version is treated as version 1.

Trees, pots, species, and the collection reference definitions by UUID through a "customFields": { "<defId>": value } map. A definition whose label already exists for the same entity type is rejected, and any values referencing it are dropped — so two bundles meant to be loaded side by side must not reuse a label within one entity type.

Photos and videos

To import photos, set each photo's uri to just the filename — e.g. "IMG_001.jpg" — and point the app at the folder containing those files when it asks. Photos not found there are skipped. A .zip archive does this automatically: the photo files travel with the bundle and are linked on import. A tree photo looks like this (the owner field is potId, speciesId or collectionId in the other three sections):

{
  "id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "treeId": "b5d2f3e1-9c7a-4b8e-c6f4-2d1a0e3b5c6d",
  "uri": "IMG_001.jpg",
  "originalFilename": "IMG_001.jpg",
  "timestamp": "2025-04-12T08:30:00.000",
  "mediaType": "image",
  "caption": "After repotting",
  "photoTag": "Front",
  "annotationUri": null,
  "posterUri": null,
  "version": 1,
  "updatedAt": null
}

timestamp is when the photo was taken, as local time with no zone; omitted, it becomes the time of the import. If all you have is a folder of pictures and the trees they belong to, the photos spreadsheet under Edit in a Spreadsheet takes a file name per row and the same folder, with no JSON to write.

Photo records carry a version (and updatedAt) like any other record. A photo's image bytes never change, so it stays at version 1 until its annotation, caption or tag is edited. Each such edit bumps the version, and the merge arbitrates it by the same version-wins rules — so an annotation edited or deleted on one device propagates to the others. A record omitting version is treated as version 1.

A photo record may carry a caption (free text) and a photoTag (a short label such as "Front", "Nebari", or any custom value the user has typed) — both plain strings, empty or omitted meaning neither is set. Editing either bumps the photo's version.

A photo's companion files travel as siblings named after it, and the app expects exactly these names: a drawn overlay is <photo-name>_annotation.png and a video's still frame is <video-name>_poster.jpg (both without the original extension — so IMG_001.jpg pairs with IMG_001_annotation.png). Set the record's annotationUri / posterUri to that filename, and place the file alongside the photo in the folder or archive.

The same four sections also hold videos, marked with "mediaType": "video" (the default, "image", may be omitted). Supported containers are .mp4, .mov, .m4v, and .webm; a record whose uri carries one of those extensions is treated as a video even without the field. A video may also carry a posterUri — the filename of a still frame (conventionally <video-name>_poster.jpg) shown in grids and lists in place of the video. The poster is optional: a video whose poster is missing imports fine and shows a placeholder instead. Videos are never annotatable.

Limits

A bundle must be under 5 MB to import, any single photo under 25 MB, and any single video under 150 MB and 30 seconds. A .zip archive caps the media it carries at 2 GB on phones and tablets, 1 GB on desktop. After merging, records with broken links are pruned — a tree referencing an unknown species, or a diary entry for a missing tree.