Catalogue
The catalogue is the photographer's spots: folders, locations, viewpoints, visits, and photos. The same records are on the phone and the desktop. A change is saved on the device first and reaches the server when the app is open and online.
Columns live on the models in catalogue/models.py. Which of them a client may write is syncapi/schema.py.
Records
A folder holds locations. Membership is flat: a location is in one folder. Every account has one Unknown folder (Folder.ensure_unknown), created with the account. A capture with no folder chosen goes there. Unknown cannot be deleted. Deleting any other folder asks twice, naming what will move, then sends that folder and its locations, viewpoints, visits, and photos to Trash together.
A location is the place. It has a name, an area, categories, subcategories, tags, parking, access notes, an optional river gauge, and a latitude and longitude. That latitude and longitude is the place anchor: the catalogue map and the forecast use it. Nearby matches the anchor or any active viewpoint shooting position, within 50 m. Categories are landscape, astro, and macro, and a location may have more than one. Subcategories are the fixed list SUBCATEGORIES in catalogue/models.py; each belongs to one parent. kept_subcategories() drops a slug when its parent category is off. An optional Environment Agency gauge is stored on river_gauge while Moving Water is selected. The forecast that uses it is in forecasting.md. Moving a viewpoint does not move the anchor and does not request another forecast.
Needs details is name_is_provisional. Quick capture fills the name with an automatic title (provisionalTitle in static/tempest/js/util.js). Leaving that title keeps the location in Needs details. Replacing it with the photographer's own name clears the flag. The list at /app/ can filter to those locations.
Lyndhurst Wood is one location. Pond Reflection, Fallen Tree, and Woodland Clearing are viewpoints on it. The list shows one tile. A badge with the count appears only when two or more viewpoints are active, in the top-right of the thumbnail, immediately left of the sync mark.
A viewpoint is one composition at that place (catalogue/viewpoints.py). It has its own name, composition notes, shooting position (latitude, longitude, coordinate_source, accuracy_m), subject position, light direction, and seasons. Categories are not copied onto it. The shooting position starts as a copy of the place anchor and can move later. Empty coordinates are allowed, and two viewpoints may share a coordinate. Grid text is derived the same way as a location and is not stored.
One non-deleted viewpoint per location is is_primary. Setting another clears the previous one in the same transaction. The last active viewpoint cannot be deleted. Deleting the primary, while others remain, is rejected until a replacement primary is saved. Trash does not count. The original viewpoint, including the one created with a new location and the one added for an existing location by migration catalogue/migrations/0008_viewpoint.py, uses UUID5 of the location id. The namespace is 8f3c2a1e-6b47-4d9e-a15c-0e7d6c5b4a39 in Python and static/tempest/js/viewpoint.js. Further viewpoints use ordinary client UUIDs.
notes, light_direction, seasons, and the subject coordinates remain on Location so a device that has not picked up this app can still write them. While the primary viewpoint still holds the previous value, the server copies the incoming value onto that viewpoint (mirror_legacy_fields). After the viewpoint has been edited on its own, later location updates leave it alone. Anchor updates never follow a viewpoint, and viewpoint edits never move the anchor.
A visit stays on the location. viewpoint_id is optional. A visit may cover several viewpoints, so existing visits are not given one. A capture that targets one viewpoint stores it.
A photo keeps location_id and has a required viewpoint_id. The viewpoint must belong to that location and that account. The photo's own GPS stays on the photo when a pin moves. Changing a viewpoint pin does not write photo GPS. "Use this photo's position" copies one photo's GPS onto the viewpoint when asked. A photo create with no viewpoint_id uses the primary, creating the UUID5 primary first if the location has none. The metadata fields are Photo.METADATA_FIELDS. metadata_source is device until the server has read the uploaded original, then server. The server's reading is the one kept.
There is no separate cover field. The tile and the location hero use the newest photo on the primary viewpoint, with the same ordering as the newest photo. If the primary has none, the newest photo on the location is used.
Capture
Add location on the phone opens the photo picker and /app/capture. The phone reads GPS, date, focal length, and the other EXIF it can (static/tempest/js/exif.js) before anything is transformed. Missing fields stay unknown and do not block the save. The capture screen shows a GPS status and an optional note, "What caught your eye?".
A photo within 50 metres of a saved location's place anchor, or of any active viewpoint shooting position, offers that location or a new one (store.nearby, radius 50). The choice defaults to a new location. Nothing is merged unless the photographer chooses it. One viewpoint on the chosen location is used without an extra step. Several viewpoints show a picker, with the option to add a new viewpoint. Choosing the existing location adds a visit for that viewpoint.
A photo with no GPS can use the device's current location, typed coordinates or an OS grid reference, or an unlocated draft. The app does not invent a pin. Current location needs a secure context; on plain HTTP the page says so. Accuracy is stored for a device fix and is not invented for photo metadata. The coordinate source is photo, device, or manual.
Save writes to IndexedDB and reports success before any upload. The original stays on the device until the server confirms receipt.
Saving a new location also creates its original viewpoint, with the same coordinates and with the capture note as composition notes. The location's own notes stay empty. Adding a photo to an existing place does not move that viewpoint.
Add manually (/app/locations/new, in the sidebar) is for a place that did not come from a phone photo. The name is required, so the location is not listed in Needs details. The place position is optional: latitude and longitude, an OS grid reference, or a UK postcode. An empty position saves an unlocated draft. The first viewpoint starts at that position, and the note is stored on the viewpoint. An optional picture is kept as the thumbnail only. It does not create a visit, and its GPS does not set the pin.
A UK postcode is resolved through postcodes.io in static/tempest/js/postcode.js and needs a connection. Grid references and latitude/longitude are converted on the device in static/tempest/js/grid.js and on the server in catalogue/grid.py. An 8-figure OS National Grid reference is derived for points in Great Britain. Outside that grid, latitude and longitude are the coordinates shown. Copy uses those strings from the device, so it works offline, with a selectable-text fallback when the clipboard is blocked.
An upload above 30 MB (TEMPEST_MAX_UPLOAD_BYTES) is rejected. An empty or unreadable file, including an iCloud photo that is not on the phone, fails clearly and does not create a finished location. A Live Photo contributes its still image.
The phone bottom bar is Locations, Add location, and Map. Folders, trips, kit, and the rest of the app are in the sidebar.
Images
catalogue/imaging.py reads metadata from the uploaded original, then writes a JPEG whose long edge is at most 2048 pixels at quality 80, and a thumbnail whose long edge is 480 pixels. The original file is dropped. The folder for a photo holds display.jpg and thumb.jpg.
After the server confirms the upload, the phone keeps the thumbnail, and it keeps the display JPEG when the capture is within the last 30 days (CACHE_DAYS in static/tempest/js/sync.js). The original is dropped on the phone at that point too. A capture older than 30 days still keeps its original until the upload succeeds, then keeps the thumbnail.
At or over an account storage limit, the upload answers storage_full and the original stays on the device, still waiting. Usage is the sum of stored display JPEGs, including Trash (console/storage.py). An empty limit means unlimited. See accounts-and-admin.md.
Browsing and the map
/app/ is a photo-led list, one tile per location. Filters are search, folder, area, category, subcategory, and Needs details, and they stay at location level. Search also matches viewpoint names and returns the location once; a matching viewpoint name on the card links to that viewpoint. On a wide screen with a connection, the list also shows a map. /app/map is the full map, with the same filters. Both maps use one pin per location, the place anchor.
The location page is /app/locations/:id. It shows the place, a hero from the primary viewpoint, and the viewpoints (thumbnail, name, primary mark, shooting position, photo count). From there a viewpoint can be opened, edited, reordered, or deleted, and another can be added. Below 992px the edit, up, down, and delete controls on that row show only their icons. Primary is set on the viewpoint page, or with Set as primary on its edit form. Edit of the place is /app/locations/:id/edit and labels the anchor as the place position used for the map and the forecast. Shooting position, subject, light, seasons, and composition notes are edited on /app/locations/:id/viewpoints/:viewpointId. That page links to the location forecast and does not show a second one. "Add a photo" uses the only viewpoint when there is one, and asks which viewpoint, or for a new one, when there are several. A photo can move between viewpoints on the same location without a new photo id.
The location map adds viewpoint shooting pins, in purple, and a parking pin when parking coordinates are set. Pins that share a coordinate open a list. Offline, the map says it needs a connection.
"Move to another location" (/app/locations/:id/combine) is online only. Offline, the page says to connect. POST /api/locations/consolidate previews without writing, then, in one transaction, reparents the chosen viewpoints, photos, visits, and trip entries onto the destination. Viewpoint names can be edited first. Place fields that differ (parking, access notes, area, categories, river gauge) stay on the destination unless the photographer chooses a source, a union of categories, or appended access notes. Composition notes travel with the viewpoint. The destination's primary stays primary. Source locations are trashed with consolidated_into_id set and cannot be restored as their own tiles. They are not purged immediately, so a queued edit of a source becomes the usual deleted-record conflict. Photo files are not copied. A client request_id makes a retry return the same result. The response is the changed sync records.
The in-app map is MapLibre with OpenFreeMap styles (static/tempest/js/maps.js): Liberty, and Dark when the theme is dark, from tiles.openfreemap.org. There is no API key. Tiles come from another origin, so the service worker does not cache them. Offline, the map says it needs a connection. Coordinates and copy still work.
Open in Google Maps is a link (googleMapsUrl in static/tempest/js/util.js) for the place anchor, a viewpoint shooting or subject position, and, when present, parking. GOOGLE_MAPS_API_KEY in the environment is unused.
Offline client
The client is plain ES modules under static/tempest/js/, with no bundler. Phoenix, Nunito Sans, exifr, and MapLibre are vendored under static/vendor/. Do not load them from a CDN. The shell is templates/app/shell.html. Routes are registered in static/tempest/js/app.js.
IndexedDB is one database per account, tempest-<userId> (static/tempest/js/db.js), still at version 3. One account's captures are never shown to another. Stores are records, photo bytes, the operation queue, meta (cursor, device id, last sync), and the last forecast per location. Viewpoints are rows in records, found with the location_id index. Opening the updated app does not wipe the database or the queue. Photo bytes are {buffer, type, size} ArrayBuffers. Safari refuses Blobs in IndexedDB in some modes. Pack and unpack only through static/tempest/js/store.js.
A location with no viewpoint row yet is shown as the UUID5 primary. That row is queued only when the photographer edits it or adds a photo. The server treats a second create of that id as the existing viewpoint, and adopts the incoming fields while the row still matches the location. The installed app swaps in new JavaScript after the queue is empty, so captures made by the previous app are pushed first and the pull then delivers viewpoints.
The service worker (pwa/views.py, templates/pwa/sw.js) caches the app shell and its static files, not /api/, map tiles, or weather. /sw.js is sent Cache-Control: no-cache. An update waits until capture is idle and the upload queue is empty (static/tempest/js/pwa.js), then the installed app offers to apply it. The installed app registers the worker only on HTTPS.
Sync runs only while the app is open. There is no background upload after it closes. The status pill and the Sync button show starting, syncing, synced, pending, offline, sign-in, and error.
Sign-out is signOut in static/tempest/js/ui/settings.js. Settings and #signout-button in app.js both call it. Do not add a second logout that POSTs /accounts/logout/ on its own.
- A queued change or photo keeps that account's database. The confirmation says the work stays on this device and uploads the next time that account signs in.
- An empty queue confirms, then logs out and deletes the local database.
- A failed logout, including while offline, does not wipe local data.
When the server session ends while the app is open, the catalogue stays usable. The banner in templates/app/shell.html reads "Your session has ended. Your captures stay on this device. Sign in to sync them." New changes stay queued until the next sign-in.
Appearance starts light (User.theme). The newest theme_updated_at wins across devices (/api/me/theme).
Sync
syncapi/engine.py applies device operations. Each one carries an op_id, the entity and record id, an action (create, update, delete, restore), the revision the device based the change on, and the changed fields. Record ids are client UUIDs.
Writes for an account take that account's PostgreSQL advisory lock. Pulls take it shared, so a pull does not skip a server_seq that is still being committed. server_seq comes from one sequence. Pull returns records above the device cursor.
Results:
| Status | Meaning |
|---|---|
applied |
The change is stored. The current record is returned. |
conflict |
Some fields were changed differently on the server since the device's base revision. The other fields are still applied. Each clashing field becomes a Conflict row. |
gone |
The record was purged from Trash. The device drops it. |
retry |
A parent record has not arrived yet. Send the operation again later. |
rejected |
The operation is invalid and will not succeed if repeated. |
A retried op_id returns its first result (AppliedOp). Those rows are pruned after 60 days. Revision snapshots, used for the field merge, are pruned after 90 days.
A conflict stores the value before, the server value, and the device value. The photographer resolves it at /app/conflicts, including with a combined value. An edit of a record that is in Trash becomes a conflict that can restore it. Two devices adding a new photo, visit, or extra viewpoint on the same location do not conflict: those are separate records. Deleting a viewpoint does not take its photos. They are moved to another viewpoint, or trashed on their own, first. Restoring a location restores its viewpoints. Restore refuses a location whose consolidated_into_id is set.
Trash keeps records for 30 days (TRASH_KEEP_DAYS). Purging leaves a Tombstone. A later edit or recreate of a purged id is gone. Tombstones that every recently seen device has synced past, or that are older than 30 days, are removed. AccountSyncState.tombstone_floor records how far that purge reached. A device whose cursor is below the floor takes a fresh snapshot. A pull with snapshot=1 continues a full snapshot below the floor. Change this together with syncapi/tests.py.
One sync pass (static/tempest/js/sync.js) checks the session, pushes queued operations in batches of 100, pulls, uploads pending photos one at a time, fetches thumbnails and recent display JPEGs, then trims the 30-day display cache.
Assistant conversations and messages are included in the pull. A device may push delete or restore for a conversation. Any other conversation write is rejected, and message writes are not a sync entity. Turns stay on the server. See assistants.md.
Export
GET /api/export, from Settings, returns a ZIP when the export feature is on. catalogue/export.py writes catalogue.json at format_version 2, the stored JPEGs, and the thumbnails for folders, locations, viewpoints, visits, and photos that are not in Trash. Photos include viewpoint_id. Visits include it when one was set. Image paths stay one JPEG per photo id. There is no importer. Trips and kit are not in the ZIP. When an admin has switched export off, the API answers feature_off.
Where a change lands
| Change | Place |
|---|---|
| Record fields and sync payload keys | catalogue/models.py, syncapi/schema.py |
| OS grid conversion | catalogue/grid.py, static/tempest/js/grid.js |
| JPEG, thumbnail, and EXIF on the server | catalogue/imaging.py |
| EXIF on the phone | static/tempest/js/exif.js |
| Viewpoint identity, backfill, primary rules | catalogue/viewpoints.py, catalogue/migrations/0008_viewpoint.py |
| Combining locations | catalogue/consolidate.py, POST /api/locations/consolidate |
| Capture, manual add, nearby match | static/tempest/js/ui/capture.js, manual.js, store.js, viewpoint.js |
| Push, pull, conflicts, tombstones | syncapi/engine.py, catalogue/trash.py |
| Device sync loop and the 30-day photo cache | static/tempest/js/sync.js |
| IndexedDB | static/tempest/js/db.js, store.js |
| Map | static/tempest/js/maps.js |
| List, filters, location page, viewpoint page, combine | static/tempest/js/ui/locations.js, location.js, edit.js, viewpoint.js, combine.js |
| Service worker and shell | pwa/views.py, templates/pwa/sw.js, templates/app/shell.html |
| ZIP export | catalogue/export.py |
| Routes | static/tempest/js/app.js |