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.
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.
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, ornullfor the most recent. Import clears an id that names no photo. -
flagged(not on the collection):trueorfalse, 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), ornull.name: text.acquired: a date ornull. -
archivedAt: the date the record left the collection, ornull. 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:truefor 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:trueorfalse. -
lengthMm(the longest side),widthMm,depthMm: outside measurements in whole millimetres, up to 2000, ornull. 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 ornull.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""(or0) 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, ornullwhile the job is open. -
recurrence:null, or{ "intervalYears": 1, "months": [3, 9] }— due in those months (1–12) every 1, 2 or 3 years.seriesIdties 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'snameandnotes, plus itsingredientsornpk.
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
updatedAtwins 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.