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}