eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– The Swift twin of web/static/schema-engine.js: the interpreter for
4// the declarative app schema (docs/app-schema.md) that renders To-do and
5// Reminders (and any schema-described app) with native components. The two
6// engines MUST behave identically β€” they are drift-gated by the shared event
7// vectors (test/event-vectors/*) and by mirrored tests (SchemaEngineTests /
8// test/schema-engine.test.mjs). Change semantics here and the JS twin changes
9// in the same PR.
10//
11// Schemas are plain JSON, so the engine works over a dynamic JSON value type
12// (AppJSON) rather than per-app Codable structs β€” one interpreter for every
13// schema-described app, exactly like the JS side.
14
15// MARK: - AppJSON: a dynamic JSON value
16
17public indirect enum AppJSON: Equatable, Sendable {
18 case null
19 case bool(Bool)
20 case number(Double)
21 case string(String)
22 case array([AppJSON])
23 case object([String: AppJSON])
24
25 /// Build from a JSONSerialization value. NSNumber wraps both booleans and
26 /// numbers β€” CFBoolean detection keeps `true` β‰  `1`, matching JS strict
27 /// equality and the vectors' expected shapes.
28 public init(any: Any?) {
29 switch any {
30 case nil, is NSNull:
31 self = .null
32 case let n as NSNumber:
33 if CFGetTypeID(n as CFTypeRef) == CFBooleanGetTypeID() {
34 self = .bool(n.boolValue)
35 } else {
36 self = .number(n.doubleValue)
37 }
38 case let s as String:
39 self = .string(s)
40 case let a as [Any]:
41 self = .array(a.map { AppJSON(any: $0) })
42 case let o as [String: Any]:
43 self = .object(o.mapValues { AppJSON(any: $0) })
44 default:
45 self = .null
46 }
47 }
48
49 public static func parse(_ data: Data) -> AppJSON? {
50 guard let any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { return nil }
51 return AppJSON(any: any)
52 }
53
54 /// Back to a JSONSerialization tree (for encrypting outgoing events).
55 public var anyValue: Any {
56 switch self {
57 case .null: return NSNull()
58 case .bool(let b): return b
59 case .number(let n):
60 // Emit whole numbers as integers so an emitted `{"when": 170…000}`
61 // matches the web's JSON byte-for-byte (no trailing ".0").
62 if n.isFinite, n == n.rounded(), abs(n) < 9e15 { return Int64(n) }
63 return n
64 case .string(let s): return s
65 case .array(let a): return a.map(\.anyValue)
66 case .object(let o): return o.mapValues(\.anyValue)
67 }
68 }
69
70 public subscript(key: String) -> AppJSON {
71 if case .object(let o) = self { return o[key] ?? .null }
72 return .null
73 }
74
75 public var arrayValue: [AppJSON] {
76 if case .array(let a) = self { return a }
77 return []
78 }
79
80 public var stringValue: String? {
81 if case .string(let s) = self { return s }
82 return nil
83 }
84
85 public var objectValue: [String: AppJSON]? {
86 if case .object(let o) = self { return o }
87 return nil
88 }
89
90 public var isNull: Bool { self == .null }
91}
92
93// MARK: - the engine
94
95public enum SchemaEngine {
96
97 // MARK: scope + values
98
99 /// The evaluation scope: named roots ("event", "item", "state", "now",
100 /// "input", "newId", "sid", "parsed", "items", "buckets"), looked up by
101 /// dotted path β€” the Swift form of the JS scope object.
102 public struct Scope {
103 public var roots: [String: AppJSON] = [:]
104 public init(_ roots: [String: AppJSON] = [:]) { self.roots = roots }
105
106 func lookup(_ path: String) -> AppJSON {
107 var parts = path.split(separator: ".").map(String.init)
108 guard !parts.isEmpty else { return .null }
109 var cur = roots[parts.removeFirst()] ?? .null
110 for p in parts { cur = cur[p] }
111 return cur
112 }
113 }
114
115 static func truthy(_ v: AppJSON) -> Bool {
116 switch v {
117 case .null: return false
118 case .bool(let b): return b
119 case .number(let n): return n != 0 && !n.isNaN
120 case .string(let s): return !s.isEmpty
121 case .array(let a): return !a.isEmpty
122 case .object: return true
123 }
124 }
125
126 static func num(_ v: AppJSON) -> Double {
127 switch v {
128 case .number(let n): return n
129 case .bool(let b): return b ? 1 : 0
130 case .string(let s): return Double(s) ?? 0
131 default: return 0
132 }
133 }
134
135 static func str(_ v: AppJSON) -> String {
136 switch v {
137 case .string(let s): return s
138 case .number(let n):
139 if n.isFinite, n == n.rounded(), abs(n) < 9e15 { return String(Int64(n)) }
140 return String(n)
141 case .bool(let b): return b ? "true" : "false"
142 default: return ""
143 }
144 }
145
146 /// A schema value: literals pass through, "$path" strings resolve against
147 /// the scope ("$$x" escapes a literal dollar), and {"add"|"sub": [a,b]}
148 /// are the only computed forms. Objects/arrays evaluate recursively.
149 public static func evalValue(_ v: AppJSON, _ scope: Scope) -> AppJSON {
150 switch v {
151 case .string(let s):
152 if s.hasPrefix("$$") { return .string(String(s.dropFirst())) }
153 if s.hasPrefix("$") { return scope.lookup(String(s.dropFirst())) }
154 return v
155 case .array(let a):
156 return .array(a.map { evalValue($0, scope) })
157 case .object(let o):
158 if let args = o["add"]?.arrayValue, o.count == 1, args.count == 2 {
159 return .number(num(evalValue(args[0], scope)) + num(evalValue(args[1], scope)))
160 }
161 if let args = o["sub"]?.arrayValue, o.count == 1, args.count == 2 {
162 return .number(num(evalValue(args[0], scope)) - num(evalValue(args[1], scope)))
163 }
164 return .object(o.mapValues { evalValue($0, scope) })
165 default:
166 return v
167 }
168 }
169
170 // MARK: conditions
171
172 public static func evalCond(_ c: AppJSON, _ scope: Scope) -> Bool {
173 guard case .object(let o) = c else { return c.isNull } // absent condition = true
174 if let v = o["truthy"] { return truthy(evalValue(v, scope)) }
175 if let v = o["not"] { return !evalCond(v, scope) }
176 if let v = o["all"] { return v.arrayValue.allSatisfy { evalCond($0, scope) } }
177 if let v = o["any"] { return v.arrayValue.contains { evalCond($0, scope) } }
178 if let v = o["eq"] { let a = v.arrayValue; return a.count == 2 && evalValue(a[0], scope) == evalValue(a[1], scope) }
179 if let v = o["empty"] { return !truthy(evalValue(v, scope)) }
180 if let v = o["gt"] { let a = v.arrayValue; return a.count == 2 && num(evalValue(a[0], scope)) > num(evalValue(a[1], scope)) }
181 if let v = o["gte"] { let a = v.arrayValue; return a.count == 2 && num(evalValue(a[0], scope)) >= num(evalValue(a[1], scope)) }
182 if let v = o["lt"] { let a = v.arrayValue; return a.count == 2 && num(evalValue(a[0], scope)) < num(evalValue(a[1], scope)) }
183 if let v = o["lte"] { let a = v.arrayValue; return a.count == 2 && num(evalValue(a[0], scope)) <= num(evalValue(a[1], scope)) }
184 return false
185 }
186
187 private static func condHolds(_ c: AppJSON, _ scope: Scope) -> Bool {
188 c.isNull ? true : evalCond(c, scope)
189 }
190
191 // MARK: templates
192
193 /// "in 2h" / "5m ago" / "now" β€” the exact strings remind.js relTime makes.
194 public static func relTime(when: Double, now: Double) -> String {
195 var diff = when - now
196 let past = diff < 0
197 diff = abs(diff)
198 let mins = (diff / 60000).rounded()
199 if mins < 1 { return "now" }
200 let s: String
201 if mins < 60 { s = String(Int(mins)) + "m" }
202 else if mins < 1440 { s = String(Int((mins / 60).rounded())) + "h" }
203 else { s = String(Int((mins / 1440).rounded())) + "d" }
204 return past ? s + " ago" : "in " + s
205 }
206
207 /// The absolute form ("Sat 4 Jul at 23:16") β€” the same shape the native
208 /// chat projection already uses (AppChatProjection.absTime). One shared
209 /// formatter: allocation is the expensive part, and formatting through an
210 /// existing DateFormatter is thread-safe on every OS we ship (folds run
211 /// detached).
212 private static let absTimeFormatter: DateFormatter = {
213 let f = DateFormatter()
214 f.dateFormat = "EEE d MMM 'at' HH:mm"
215 return f
216 }()
217
218 public static func absTime(_ ms: Double) -> String {
219 absTimeFormatter.string(from: Date(timeIntervalSince1970: ms / 1000))
220 }
221
222 static func applyFilter(_ v: AppJSON, _ filter: String, _ scope: Scope) -> String {
223 switch filter {
224 case "count": return String(v.arrayValue.count)
225 case "reltime": return relTime(when: num(v), now: num(scope.roots["now"] ?? .null))
226 case "abstime": return absTime(num(v))
227 case "number": return str(.number(num(v)))
228 default: return str(v)
229 }
230 }
231
232 /// Interpolates "{path}" / "{path|filter}" against the scope; "{{" escapes.
233 public static func template(_ tpl: String, _ scope: Scope) -> String {
234 var out = ""
235 let chars = Array(tpl)
236 var i = 0
237 while i < chars.count {
238 if chars[i] == "{" {
239 if i + 1 < chars.count, chars[i + 1] == "{" {
240 out.append("{")
241 i += 2
242 continue
243 }
244 guard let end = chars[i...].firstIndex(of: "}") else {
245 out.append(String(chars[i...]))
246 break
247 }
248 let expr = String(chars[(i + 1)..<end])
249 let parts = expr.split(separator: "|", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
250 let v = scope.lookup(parts.first ?? "")
251 out += parts.count > 1 ? applyFilter(v, parts[1], scope) : str(v)
252 i = end + 1
253 } else {
254 out.append(chars[i])
255 i += 1
256 }
257 }
258 return out
259 }
260
261 // MARK: state & fold
262
263 static func zero(for type: String?) -> AppJSON {
264 switch type {
265 case "string": return .string("")
266 case "bool": return .bool(false)
267 case "number", "time": return .number(0)
268 default: return .null
269 }
270 }
271
272 /// Coerce a folded value to its declared field type; missing/null becomes
273 /// the zero value β€” the schema form of the reducers' `e.ts || 0`.
274 static func coerce(_ v: AppJSON, to type: String?) -> AppJSON {
275 if v.isNull { return zero(for: type) }
276 switch type {
277 case "string": return .string(str(v))
278 case "bool": return .bool(truthy(v))
279 case "number", "time": return .number(num(v))
280 default: return v
281 }
282 }
283
284 public static func initialState(_ schema: AppJSON) -> AppJSON {
285 var out: [String: AppJSON] = [:]
286 for key in (schema["state"].objectValue ?? [:]).keys {
287 out[key] = .array([])
288 }
289 return .object(out)
290 }
291
292 static func collectionKey(_ schema: AppJSON, _ name: String) -> String {
293 schema["state"][name]["key"].stringValue ?? "id"
294 }
295
296 static func fieldType(_ schema: AppJSON, _ collection: String, _ field: String) -> String? {
297 schema["state"][collection]["fields"][field].stringValue
298 }
299
300 /// Fold one decrypted event into state via the schema's declarative ops.
301 /// Unknown types and failed `require` guards return state unchanged; `add`
302 /// dedupes by the collection key; `update`/`remove` on a missing id no-op.
303 public static func fold(_ schema: AppJSON, state: AppJSON, event: AppJSON) -> AppJSON {
304 guard let type = event["type"].stringValue else { return state }
305 let spec = schema["events"][type]
306 guard !spec.isNull else { return state }
307 let scope = Scope(["event": event, "state": state])
308 for r in spec["require"].arrayValue {
309 if !truthy(evalValue(r, scope)) { return state }
310 }
311 var next = state
312 for op in spec["fold"].arrayValue {
313 next = applyFoldOp(schema, next, op, event: event)
314 }
315 return next
316 }
317
318 static func applyFoldOp(_ schema: AppJSON, _ state: AppJSON, _ op: AppJSON, event: AppJSON) -> AppJSON {
319 let scope = Scope(["event": event, "state": state])
320 var out = state.objectValue ?? [:]
321
322 if case .object(let add) = op["add"] {
323 guard let name = add["to"]?.stringValue else { return state }
324 var list = state[name].arrayValue
325 var item: [String: AppJSON] = [:]
326 for (field, v) in add["item"]?.objectValue ?? [:] {
327 item[field] = coerce(evalValue(v, scope), to: fieldType(schema, name, field))
328 }
329 let keyField = collectionKey(schema, name)
330 let itemKey = item[keyField] ?? .null
331 if !list.contains(where: { $0[keyField] == itemKey }) {
332 list.append(.object(item))
333 }
334 out[name] = .array(list)
335 return .object(out)
336 }
337
338 if case .object(let update) = op["update"] {
339 guard let name = update["in"]?.stringValue else { return state }
340 let keyField = collectionKey(schema, name)
341 let id = evalValue(update["id"] ?? .null, scope)
342 let list = state[name].arrayValue.map { item -> AppJSON in
343 guard item[keyField] == id, var obj = item.objectValue else { return item }
344 for (field, v) in update["set"]?.objectValue ?? [:] {
345 obj[field] = coerce(evalValue(v, scope), to: fieldType(schema, name, field))
346 }
347 return .object(obj)
348 }
349 out[name] = .array(list)
350 return .object(out)
351 }
352
353 if case .object(let remove) = op["remove"] {
354 guard let name = remove["from"]?.stringValue else { return state }
355 let keyField = collectionKey(schema, name)
356 let id = evalValue(remove["id"] ?? .null, scope)
357 out[name] = .array(state[name].arrayValue.filter { $0[keyField] != id })
358 return .object(out)
359 }
360
361 if case .object(let move) = op["move"] {
362 guard let name = move["in"]?.stringValue else { return state }
363 let keyField = collectionKey(schema, name)
364 let id = evalValue(move["id"] ?? .null, scope)
365 let list = state[name].arrayValue
366 guard let from = list.firstIndex(where: { $0[keyField] == id }) else {
367 out[name] = .array(list)
368 return .object(out)
369 }
370 // `after` names the item this one now follows; ""/missing = the
371 // list's start. An unknown `after` id (removed concurrently) is a
372 // no-op β€” the rule is pinned in the JS twin; keep them identical.
373 let after = str(evalValue(move["after"] ?? .null, scope))
374 var next = list
375 let moved = next.remove(at: from)
376 var to = 0
377 if !after.isEmpty {
378 guard let anchor = next.firstIndex(where: { str($0[keyField]) == after }) else {
379 out[name] = .array(list)
380 return .object(out)
381 }
382 to = anchor + 1
383 }
384 next.insert(moved, at: to)
385 out[name] = .array(next)
386 return .object(out)
387 }
388
389 if case .object(let sort) = op["sort"] {
390 guard let name = sort["in"]?.stringValue, let by = sort["by"]?.stringValue else { return state }
391 let dir: Double = sort["dir"]?.stringValue == "desc" ? -1 : 1
392 // Index tiebreak = stable on every runtime (Swift's sort isn't
393 // guaranteed stable; the JS twin decorates identically).
394 let decorated = state[name].arrayValue.enumerated().map { ($0.offset, $0.element) }
395 let sorted = decorated.sorted { a, b in
396 let av = num(a.1[by]), bv = num(b.1[by])
397 if av != bv { return (av - bv) * dir < 0 }
398 return a.0 < b.0
399 }
400 out[name] = .array(sorted.map(\.1))
401 return .object(out)
402 }
403
404 return state
405 }
406
407 // MARK: partition (the grace-period buckets)
408
409 public struct Buckets {
410 public var active: [AppJSON] = []
411 public var grace: [AppJSON] = []
412 public var archived: [AppJSON] = []
413 }
414
415 /// Split the primary collection by completion timestamp β€” derived from the
416 /// clock at render time, never by a timer mutating state. A flagged item
417 /// with no timestamp (legacy data) reads as long-archived.
418 public static func partition(_ schema: AppJSON, state: AppJSON, now: Double) -> Buckets {
419 let items = state[schema["collection"].stringValue ?? "items"].arrayValue
420 var b = Buckets()
421 guard case .object = schema["partition"] else {
422 b.active = items
423 return b
424 }
425 let flag = schema["partition"]["flag"].stringValue ?? ""
426 let atField = schema["partition"]["at"].stringValue ?? ""
427 let graceMs = num(schema["partition"]["graceMs"])
428 for item in items {
429 let at = truthy(item[flag]) ? max(num(item[atField]), 1) : 0
430 if at == 0 { b.active.append(item) }
431 else if now - at < graceMs { b.grace.append(item) }
432 else { b.archived.append(item) }
433 }
434 return b
435 }
436
437 // MARK: the list render model
438
439 public struct ListRow: Identifiable {
440 public let id: String // the item's key value
441 public let item: AppJSON
442 public let grace: Bool
443 }
444
445 public struct ListSection: Identifiable {
446 public let id: String
447 public let title: String
448 public let collapsible: Bool
449 public let collapsed: Bool
450 /// Manual reorder: the action a drag runs ("" = not reorderable, or
451 /// suppressed while searching). `descending` tells the renderer how a
452 /// visual drop maps to the array anchor.
453 public let reorder: String
454 public let descending: Bool
455 public let rows: [ListRow]
456 }
457
458 public struct ListModel {
459 public var summary = ""
460 public var sections: [ListSection] = []
461 public var empty = ""
462 }
463
464 /// Resolve the main view against state + the clock + transient UI state
465 /// (search query, collapsed overrides) into a plain description the
466 /// renderer draws directly β€” the Swift twin of listModel() in JS.
467 public static func listModel(_ schema: AppJSON, state: AppJSON, now: Double,
468 query: String = "", collapsed: [String: Bool] = [:]) -> ListModel {
469 var model = ListModel()
470 let view = schema["views"]["main"]
471 guard !view.isNull else { return model }
472 let collection = schema["collection"].stringValue ?? "items"
473 let items = state[collection].arrayValue
474 let buckets = partition(schema, state: state, now: now)
475 let keyField = collectionKey(schema, collection)
476 let scope = Scope([
477 "state": state, "items": state[collection], "now": .number(now),
478 "buckets": .object(["active": .array(buckets.active), "grace": .array(buckets.grace), "archived": .array(buckets.archived)]),
479 ])
480
481 for c in view["summary"].arrayValue {
482 if condHolds(c["if"], scope) {
483 model.summary = template(c["text"].stringValue ?? "", scope)
484 break
485 }
486 }
487
488 let q = query.trimmingCharacters(in: .whitespaces).lowercased()
489 let searchFields = view["search"]["fields"].arrayValue.compactMap(\.stringValue)
490 func matches(_ item: AppJSON) -> Bool {
491 if q.isEmpty { return true }
492 return searchFields.contains { str(item[$0]).lowercased().contains(q) }
493 }
494
495 let graceKeys = Set(buckets.grace.map { str($0[keyField]) })
496 // AppJSON isn't Hashable; index items by key value (unique per item).
497 var indexByKey: [String: Int] = [:]
498 for (i, item) in items.enumerated() { indexByKey[str(item[keyField])] = i }
499
500 for s in view["sections"].arrayValue {
501 let sources: [String] = {
502 if let one = s["source"].stringValue { return [one] }
503 return s["source"].arrayValue.compactMap(\.stringValue)
504 }()
505 var rows: [AppJSON] = []
506 for name in sources {
507 switch name {
508 case "active": rows += buckets.active
509 case "grace": rows += buckets.grace
510 case "archived": rows += buckets.archived
511 default: break
512 }
513 }
514 rows = rows.filter(matches)
515 if let by = s["order"]["by"].stringValue {
516 let dir: Double = s["order"]["dir"].stringValue == "desc" ? -1 : 1
517 let decorated = rows.map { (indexByKey[str($0[keyField])] ?? 0, $0) }
518 rows = decorated.sorted { a, b in
519 let av = by == "_index" ? Double(a.0) : num(a.1[by])
520 let bv = by == "_index" ? Double(b.0) : num(b.1[by])
521 if av != bv { return (av - bv) * dir < 0 }
522 return a.0 < b.0
523 }.map(\.1)
524 }
525 let sid = s["id"].stringValue ?? ""
526 let isCollapsible = truthy(s["collapsible"])
527 let isCollapsed = isCollapsible ? (collapsed[sid] ?? truthy(s["collapsed"])) : false
528 var titleScope = scope
529 titleScope.roots["count"] = .number(Double(rows.count))
530 model.sections.append(ListSection(
531 id: sid,
532 title: s["title"].stringValue.map { template($0, titleScope) } ?? "",
533 collapsible: isCollapsible,
534 collapsed: isCollapsed,
535 reorder: q.isEmpty ? (s["reorder"]["action"].stringValue ?? "") : "",
536 descending: s["order"]["dir"].stringValue == "desc",
537 rows: rows.map { ListRow(id: str($0[keyField]), item: $0, grace: graceKeys.contains(str($0[keyField]))) }
538 ))
539 }
540
541 let visibleRows = model.sections.filter { !$0.collapsible }.reduce(0) { $0 + $1.rows.count }
542 if visibleRows == 0 {
543 for c in view["empty"].arrayValue {
544 if condHolds(c["if"], scope) {
545 model.empty = template(c["text"].stringValue ?? "", scope)
546 break
547 }
548 }
549 }
550 return model
551 }
552
553 // MARK: one row's presentation
554
555 public struct RowModel {
556 public var title = ""
557 public var strike = false
558 public var muted = false
559 public var subtitle = ""
560 public var accent = false
561 public var note = ""
562 }
563
564 public static func rowModel(_ schema: AppJSON, item: AppJSON, now: Double) -> RowModel {
565 let row = schema["views"]["main"]["row"]
566 let scope = Scope(["item": item, "now": .number(now)])
567 var out = RowModel()
568 if case .object = row["title"] {
569 out.title = template(row["title"]["text"].stringValue ?? "", scope)
570 out.strike = row["title"]["strike"].isNull ? false : evalCond(row["title"]["strike"], scope)
571 out.muted = row["title"]["muted"].isNull ? false : evalCond(row["title"]["muted"], scope)
572 }
573 for c in row["subtitle"].arrayValue {
574 if condHolds(c["if"], scope) {
575 out.subtitle = template(c["text"].stringValue ?? "", scope)
576 out.accent = c["accent"].isNull ? false : evalCond(c["accent"], scope)
577 break
578 }
579 }
580 for c in row["note"].arrayValue {
581 if condHolds(c["if"], scope) {
582 out.note = template(c["text"].stringValue ?? "", scope)
583 break
584 }
585 }
586 return out
587 }
588
589 // MARK: the detail ("show") view model
590
591 public struct DetailDefinition: Identifiable {
592 public let id = UUID()
593 public let term: String
594 public let value: String
595 }
596
597 public struct DetailButton: Identifiable {
598 public let id = UUID()
599 public let label: String
600 public let action: String
601 public let destructive: Bool
602 public let dismiss: Bool
603 }
604
605 public enum DetailSection: Identifiable {
606 case definitions([DetailDefinition])
607 case text(String)
608 case buttons([DetailButton])
609 public var id: String {
610 switch self {
611 case .definitions(let d): return "defs:" + d.map(\.term).joined(separator: ",")
612 case .text(let t): return "text:" + t
613 case .buttons(let b): return "btns:" + b.map(\.label).joined(separator: ",")
614 }
615 }
616 }
617
618 public struct DetailModel {
619 public var title = ""
620 public var sections: [DetailSection] = []
621 }
622
623 public static func detailModel(_ schema: AppJSON, item: AppJSON, now: Double) -> DetailModel? {
624 let view = schema["views"]["detail"]
625 guard !view.isNull else { return nil }
626 let scope = Scope(["item": item, "now": .number(now)])
627 var model = DetailModel(title: template(view["title"].stringValue ?? "", scope))
628 for s in view["sections"].arrayValue {
629 switch s["kind"].stringValue {
630 case "definitions":
631 let rows = s["rows"].arrayValue.filter { condHolds($0["if"], scope) }.map {
632 DetailDefinition(term: $0["term"].stringValue ?? "", value: template($0["value"].stringValue ?? "", scope))
633 }
634 if !rows.isEmpty { model.sections.append(.definitions(rows)) }
635 case "text":
636 if condHolds(s["if"], scope) {
637 let body = template(s["body"].stringValue ?? "", scope)
638 if !body.isEmpty { model.sections.append(.text(body)) }
639 }
640 case "buttons":
641 let buttons = s["items"].arrayValue.filter { condHolds($0["if"], scope) }.map {
642 DetailButton(label: $0["label"].stringValue ?? "",
643 action: $0["action"].stringValue ?? "",
644 destructive: $0["role"].stringValue == "destructive",
645 dismiss: truthy($0["dismiss"]))
646 }
647 if !buttons.isEmpty { model.sections.append(.buttons(buttons)) }
648 default:
649 break
650 }
651 }
652 return model
653 }
654
655 // MARK: actions
656
657 /// Run a named action. `item` binds a row action's `$item`; `input` binds
658 /// the composer's `$input` (already trimmed); `extra` carries any
659 /// trigger-specific bindings (a reorder's `$after`). `$newId` is minted
660 /// once per run so a schedule step and the emit recording its sid share
661 /// the id.
662 @discardableResult
663 @MainActor
664 public static func runAction(_ schema: AppJSON, name: String,
665 item: AppJSON = .null, input: String = "",
666 extra: [String: AppJSON] = [:],
667 io: SchemaActionIO) async -> Bool {
668 let action = schema["actions"][name]
669 guard !action.isNull else { return false }
670 var scope = Scope([
671 "item": item,
672 "input": .string(input),
673 "newId": .string(io.newId()),
674 "sid": .string(""),
675 "parsed": .null,
676 ])
677 for (k, v) in extra { scope.roots[k] = v }
678 for r in action["require"].arrayValue {
679 if !truthy(evalValue(r, scope)) { return false }
680 }
681 for step in action["steps"].arrayValue {
682 if !step["if"].isNull, !evalCond(step["if"], scope) { continue }
683 if case .object(let parse) = step["parse"] {
684 let text = str(evalValue(parse["text"] ?? .null, scope))
685 guard let parsed = io.parse(kind: parse["kind"]?.stringValue ?? "", text: text) else {
686 if let hint = parse["onFail"]?["hint"].stringValue { io.hint(hint) }
687 return false
688 }
689 scope.roots["parsed"] = parsed
690 } else if case .object(let sched) = step["schedule"] {
691 let at = num(evalValue(sched["at"] ?? .null, scope))
692 let event = evalValue(sched["event"] ?? .null, scope)
693 let sid = await io.schedule(event: event, atMs: at)
694 scope.roots["sid"] = .string(sid ?? "")
695 } else if !step["unschedule"].isNull {
696 await io.unschedule(str(evalValue(step["unschedule"], scope)))
697 } else if !step["emit"].isNull {
698 _ = await io.emit(evalValue(step["emit"], scope))
699 }
700 }
701 return true
702 }
703
704 // MARK: ticker rules
705
706 /// Evaluate the schema's per-item timed rules (Reminders' client-side
707 /// fallback firing) and return the events now due. `fired` is the host's
708 /// per-session dedupe set for `once` rules β€” pass the same set every tick.
709 public static func tickerRules(_ schema: AppJSON, state: AppJSON, now: Double,
710 fired: inout Set<String>) -> [AppJSON] {
711 let ticker = schema["ticker"]
712 guard !ticker.isNull else { return [] }
713 let collection = schema["collection"].stringValue ?? "items"
714 let keyField = collectionKey(schema, collection)
715 var out: [AppJSON] = []
716 for rule in ticker["perItem"].arrayValue {
717 for item in state[collection].arrayValue {
718 let scope = Scope(["item": item, "now": .number(now)])
719 guard evalCond(rule["if"], scope) else { continue }
720 let dedupe = str(item[keyField])
721 if truthy(rule["once"]) {
722 if fired.contains(dedupe) { continue }
723 fired.insert(dedupe)
724 }
725 out.append(evalValue(rule["emit"], scope))
726 }
727 }
728 return out
729 }
730}
731
732/// The io surface a host provides to run schema actions β€” emit/schedule/
733/// unschedule are the app-event transport (SchemaAppStore implements them over
734/// the same wire the web uses); parse runs a named parser (kind "reminder" β†’
735/// ReminderParse); hint surfaces inline feedback (never a floating toast).
736/// Main-actor: actions are user gestures acting on main-actor state.
737@MainActor
738public protocol SchemaActionIO: AnyObject {
739 func emit(_ event: AppJSON) async -> Bool
740 func schedule(event: AppJSON, atMs: Double) async -> String?
741 func unschedule(_ sid: String) async
742 func newId() -> String
743 func parse(kind: String, text: String) -> AppJSON?
744 func hint(_ text: String)
745}
746