Waypoint.res

  1// src/ffi/Waypoint.res
  2// FFI bindings for waypoint.ts custom event
  3
  4// Route type - represents a route with optional fields
  5// Matches Go Route struct with id, d (distance), t (time), bbox (bounding box)
  6type route = {
  7  id: option<string>,
  8  d: option<float>,
  9  t: option<float>,
 10  bbox: option<array<float>>,
 11}
 12
 13// Type for waypoint attributes - all fields required per requirements
 14// Note: point is a tuple of (longitude, latitude) as floats
 15// Note: from is a map of string keys to route values (map[string]Route in Go)
 16// contracts:ignore TypeEquivalence - point uses (float, float) tuple for [lon, lat] coordinates
 17// contracts:ignore TypeEquivalence - from uses option<Dict.t<route>> for map[string]Route
 18// contracts:ignore TypeEquivalence - cons uses array<string> for []string
 19type attributes = {
 20  // contracts:ignore TypeEquivalence - string matches Go string and TS string
 21  id: string,
 22  // contracts:ignore TypeEquivalence - (float, float) tuple matches Go []float64 and TS [number, number]
 23  point: (float, float),
 24  // contracts:ignore TypeEquivalence - option<Dict.t<route>> matches Go map[string]Route and TS Record<string, Route> | null
 25  from: option<Dict.t<route>>,
 26  // contracts:ignore TypeEquivalence - string matches Go string and TS string
 27  label: string,
 28  // contracts:ignore TypeEquivalence - string matches Go string and TS string
 29  gid: string,
 30  // contracts:ignore TypeEquivalence - string matches Go string and TS string
 31  country: string,
 32  // contracts:ignore TypeEquivalence - array<string> matches Go []string and TS string[]
 33  cons: array<string>,
 34  // contracts:ignore TypeEquivalence - bool matches Go bool and TS boolean
 35  nonroutable: bool,
 36  // contracts:ignore TypeEquivalence - error uses option<array<string>> for []string
 37  error: option<array<string>>,
 38}
 39
 40// Decode a route from JSON
 41let decodeRoute = (json: JSON.t): option<route> => {
 42  switch json->JSON.Decode.object {
 43  | Some(dict) => {
 44      let id = dict->Dict.get("id")->Option.flatMap(JSON.Decode.string)
 45      let d = dict->Dict.get("d")->Option.flatMap(JSON.Decode.float)
 46      let t = dict->Dict.get("t")->Option.flatMap(JSON.Decode.float)
 47      let bbox = switch dict->Dict.get("bbox")->Option.flatMap(JSON.Decode.array) {
 48      | Some(arr) => {
 49          let floats = arr->Array.filterMap(JSON.Decode.float)
 50          Some(floats)
 51        }
 52      | None => None
 53      }
 54      Some({id, d, t, bbox})
 55    }
 56  | None => None
 57  }
 58}
 59
 60// Decode a single waypoint from JSON
 61// Returns Result with Ok(attributes) on success, Error(message) on failure
 62let fromJSON = (json: JSON.t): Belt.Result.t<attributes, string> => {
 63  switch json->JSON.Decode.object {
 64  | Some(dict) => {
 65      // Required fields
 66      let id = dict->Dict.get("id")->Option.flatMap(JSON.Decode.string)
 67      let point = switch dict->Dict.get("point")->Option.flatMap(JSON.Decode.array) {
 68      | Some(arr) => {
 69          let lon = arr->Array.get(0)->Option.flatMap(JSON.Decode.float)
 70          let lat = arr->Array.get(1)->Option.flatMap(JSON.Decode.float)
 71          switch (lon, lat) {
 72          | (Some(lon), Some(lat)) => Some((lon, lat))
 73          | _ => None
 74          }
 75        }
 76      | None => None
 77      }
 78      let label = dict->Dict.get("label")->Option.flatMap(JSON.Decode.string)
 79      let gid = dict->Dict.get("gid")->Option.flatMap(JSON.Decode.string)
 80      let country = dict->Dict.get("country")->Option.flatMap(JSON.Decode.string)
 81
 82      // Check all required fields are present
 83      switch (id, point, label, gid, country) {
 84      | (Some(id), Some(point), Some(label), Some(gid), Some(country)) => {
 85          // Parse optional from field (now a map of string -> route)
 86          let from = switch dict->Dict.get("from")->Option.flatMap(JSON.Decode.object) {
 87          | Some(fromDict) => {
 88              let routeDict =
 89                fromDict
 90                ->Dict.toArray
 91                ->Array.filterMap(((key, value)) => {
 92                  switch decodeRoute(value) {
 93                  | Some(route) => Some((key, route))
 94                  | None => None
 95                  }
 96                })
 97                ->Dict.fromArray
 98              Some(routeDict)
 99            }
100          | None => None
101          }
102
103          // Parse required cons field (now array<string>)
104          let cons = switch dict->Dict.get("cons")->Option.flatMap(JSON.Decode.array) {
105          | Some(arr) => arr->Array.filterMap(JSON.Decode.string)
106          | None => []
107          }
108
109          // Parse nonroutable field (default to false if not present)
110          let nonroutable =
111            dict->Dict.get("nonroutable")->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
112
113          // Parse error field (optional array of strings)
114          let error = switch dict->Dict.get("error")->Option.flatMap(JSON.Decode.array) {
115          | Some(arr) => {
116              let strings = arr->Array.filterMap(JSON.Decode.string)
117              if strings->Array.length > 0 {
118                Some(strings)
119              } else {
120                None
121              }
122            }
123          | None => None
124          }
125
126          Ok({
127            id,
128            point,
129            from,
130            label,
131            gid,
132            country,
133            cons,
134            nonroutable,
135            error,
136          })
137        }
138      | _ => Error("Missing required fields in waypoint JSON")
139      }
140    }
141  | None => Error("Waypoint JSON is not an object")
142  }
143}
144
145// Decode an array of waypoints from Yjs array JSON
146// Returns array of successfully decoded waypoints, logs errors for failed decodes
147let fromYjsArray = (jsonArray: array<JSON.t>): array<attributes> => {
148  jsonArray
149  ->Array.map(json => {
150    switch fromJSON(json) {
151    | Ok(waypoint) => Some(waypoint)
152    | Error(msg) => {
153        Log.error("Failed to decode waypoint", ~kv={"error": msg})
154        None
155      }
156    }
157  })
158  ->Array.filterMap(x => x)
159}
160
161// Type for the range in the event
162// from: start position in document
163// to: end position in document
164type range = {from: int, to: int}
165
166// Type for the WaypointSearchEvent dispatched by the Waypoint TipTap extension
167// range: the document range where the @ trigger occurred
168// content: function that takes waypoint attributes and returns TipTap content as JSON
169//          this is used later to construct and insert the waypoint node after API lookup
170// Note: content returns JSON.t (plain JS objects), not Rescript variants, to match
171// the runtime representation of the JS content function in waypoint.ts
172type waypointSearchEvent = {
173  range: option<range>,
174  content: attributes => JSON.t,
175}
176
177// Extract waypoints from TipTap JSON document structure
178// Traverses the document recursively to find all waypoint nodes
179
180// Recursively extract waypoints from a TipTap JSON node
181let rec extractFromNode = (node: JSON.t): array<attributes> => {
182  switch node->JSON.Decode.object {
183  | Some(dict) => {
184      let nodeType = dict->Dict.get("type")->Option.flatMap(JSON.Decode.string)
185      let attrsJson = dict->Dict.get("attrs")
186      let contentJson = dict->Dict.get("content")
187
188      // Check if this is a waypoint node
189      let thisWaypoint = switch (nodeType, attrsJson) {
190      | (Some("waypoint"), Some(attrs)) => switch fromJSON(attrs) {
191        | Ok(wp) => [wp]
192        | Error(_) => []
193        }
194      | _ => []
195      }
196
197      // Recurse into content children
198      let childWaypoints = switch contentJson {
199      | Some(content) =>
200        switch content->JSON.Decode.array {
201        | Some(arr) => arr->Array.map(extractFromNode)->Array.flat
202        | None => []
203        }
204      | None => []
205      }
206
207      Array.concat(thisWaypoint, childWaypoints)
208    }
209  | None => []
210  }
211}
212
213// Extract waypoints from full TipTap document JSON
214let extractFromDocument = (docJson: JSON.t): array<attributes> => {
215  extractFromNode(docJson)
216}
217
218// Find a waypoint by its ID
219let findById = (waypoints: array<attributes>, id: string): option<attributes> => {
220  waypoints->Array.find(wp => wp.id == id)
221}
222
223// Check if a waypoint is the trip origin (first waypoint with empty from dict)
224let isOrigin = (wp: attributes): bool => {
225  if wp.nonroutable {
226    false
227  } else {
228    switch wp.from {
229    | Some(dict) => Dict.toArray(dict)->Array.length == 0
230    | None => true
231    }
232  }
233}
234
235// Get the origin point from the list, returns at most one
236let getOrigin = (waypoints: array<attributes>): option<attributes> => {
237  waypoints->Array.find(wp => isOrigin(wp))
238}