docgen_bases/interactive.rs
1//! Interactive-bases payload builder (M1 of the "interactive bases" feature).
2//!
3//! Pure, no I/O. Given the same filtered/sorted rows the static renderer emits,
4//! this produces the compact JSON payload (contract v1) that the client-side
5//! island (`islands/bases.js`, M4) hydrates against. The payload carries only the
6//! *keys* needed for sort/filter/search/facet — never display HTML — so rendering
7//! stays single-source in `render.rs`.
8//!
9//! See `.overnight/interactive-bases/SCHEMA.md` for the frozen contract.
10
11use std::collections::BTreeMap;
12use std::collections::BTreeSet;
13
14use serde_json::{json, Map, Value as J};
15
16use crate::model::{BaseFile, SortKey, View, ViewInteractive};
17use crate::render::{self, column_header, RenderOptions};
18use crate::semver;
19use crate::value::Value;
20
21/// Default enum-vs-text cardinality threshold.
22const DEFAULT_MAX_ENUM: usize = 40;
23
24/// A row as seen by the payload builder: a stable id plus its evaluated cells.
25pub(crate) struct RowView<'a> {
26 pub id: usize,
27 pub cells: &'a BTreeMap<String, Value>,
28}
29
30/// Whether the interactive island should be enabled for `view` within `base`.
31///
32/// The actual gating (whether to emit interactive HTML at all) is performed by
33/// the host in M3; this helper encodes the precedence so both sides agree:
34/// base-level `docgenInteractive: false` disables everything; a per-view
35/// `docgenInteractive.enabled: false` disables that view; otherwise enabled.
36pub fn view_interactive_enabled(base: &BaseFile, view: &View) -> bool {
37 if let Some(toggle) = &base.docgen_interactive {
38 if !toggle.enabled() {
39 return false;
40 }
41 }
42 if let Some(iv) = &view.interactive {
43 if iv.enabled == Some(false) {
44 return false;
45 }
46 }
47 true
48}
49
50/// The concrete type tag for a value, per SCHEMA "Cell object".
51fn type_tag(v: &Value) -> &'static str {
52 match v {
53 Value::Null => "null",
54 Value::Bool(_) => "bool",
55 Value::Number(_) => "num",
56 Value::Str(_) => "str",
57 Value::Date(_) => "date",
58 Value::Duration(_) => "dur",
59 Value::List(_) => "list",
60 Value::Object(_) => "obj",
61 Value::Link(_) => "link",
62 }
63}
64
65/// Project one cell to its compact JSON object (omitting inapplicable fields).
66///
67/// `semver` marks a version column: every cell in one carries an `sv` sort key
68/// so the island orders it by string comparison instead of parsing versions
69/// itself (see `semver::column_sort_key`).
70fn project_cell(v: &Value, semver_col: bool) -> J {
71 let mut m = Map::new();
72 m.insert("t".into(), json!(type_tag(v)));
73 m.insert("d".into(), json!(v.display()));
74 if let Some(n) = v.as_number() {
75 m.insert("num".into(), json!(n));
76 }
77 if let Value::Date(d) = v {
78 m.insert("epoch".into(), json!(d.epoch_millis()));
79 }
80 if semver_col {
81 m.insert("sv".into(), json!(semver::column_sort_key(v)));
82 }
83 // Facet tokens only for lists (scalars default to `[d]` island-side). An empty
84 // list omits `f` (island → "(empty)").
85 if let Value::List(items) = v {
86 if !items.is_empty() {
87 let tokens: Vec<String> = items.iter().map(Value::display).collect();
88 m.insert("f".into(), json!(tokens));
89 }
90 }
91 if v.is_empty() {
92 m.insert("empty".into(), json!(true));
93 }
94 J::Object(m)
95}
96
97/// Columns that stand in for the note's title: always sortable + text filter.
98fn is_title_column(key: &str) -> bool {
99 matches!(
100 key,
101 "file.name" | "file.basename" | "file.file" | "file.path"
102 )
103}
104
105/// Inferred, override-resolved column metadata.
106struct ColMeta {
107 type_: &'static str,
108 sortable: bool,
109 filter: &'static str,
110}
111
112/// Infer a column's dominant type + default widget from its non-empty cells,
113/// then apply any per-view overrides.
114fn infer_column(
115 key: &str,
116 rows: &[RowView],
117 max_enum: usize,
118 overrides: Option<&ViewInteractive>,
119) -> ColMeta {
120 let non_empty: Vec<&Value> = rows
121 .iter()
122 .filter_map(|r| r.cells.get(key))
123 .filter(|v| !v.is_empty())
124 .collect();
125
126 // Title columns are text-searchable by default regardless of content.
127 let (type_, mut sortable, mut filter) = if is_title_column(key) {
128 ("str", true, "text")
129 } else if non_empty.is_empty() {
130 // No data to infer from: a plain, sortable, no-widget string column.
131 ("str", true, "none")
132 } else {
133 infer_from_cells(&non_empty, max_enum)
134 };
135
136 // Apply per-view overrides (explicit > auto).
137 if let Some(iv) = overrides {
138 if let Some(w) = iv.filters.get(key) {
139 filter = normalize_widget(w).unwrap_or(filter);
140 }
141 if let Some(&s) = iv.sortable.get(key) {
142 sortable = s;
143 }
144 }
145 ColMeta {
146 type_,
147 sortable,
148 filter,
149 }
150}
151
152/// Core type inference over a column's non-empty cells.
153fn infer_from_cells(non_empty: &[&Value], max_enum: usize) -> (&'static str, bool, &'static str) {
154 let tags: BTreeSet<&'static str> = non_empty.iter().map(|v| type_tag(v)).collect();
155
156 let type_: &'static str = if tags.len() == 1 {
157 // All the same concrete type.
158 tags.iter().next().copied().unwrap()
159 } else if non_empty.iter().all(|v| v.as_number().is_some()) {
160 // Mixed but all numeric-coercible.
161 "num"
162 } else {
163 "str"
164 };
165
166 match type_ {
167 "date" => ("date", true, "date"),
168 "num" | "dur" => (type_, true, "number"),
169 "bool" => ("bool", true, "boolean"),
170 "list" => {
171 // Multi-value column: enum over the distinct item tokens, but cap
172 // cardinality like scalars so a high-cardinality list (e.g. free-form
173 // tags across thousands of notes) falls back to text-search coverage
174 // rather than emitting thousands of facet checkboxes.
175 let mut tokens: BTreeSet<String> = BTreeSet::new();
176 for v in non_empty {
177 if let Value::List(items) = v {
178 for item in items {
179 tokens.insert(item.display());
180 }
181 } else {
182 tokens.insert(v.display());
183 }
184 }
185 let filter = if tokens.len() <= max_enum {
186 "enum"
187 } else {
188 "text"
189 };
190 ("list", true, filter)
191 }
192 "obj" => ("obj", true, "none"),
193 // "str" | "link" (and any fallback): enum if low-cardinality else text.
194 _ => {
195 let distinct: BTreeSet<String> = non_empty.iter().map(|v| v.display()).collect();
196 let filter = if distinct.len() <= max_enum {
197 "enum"
198 } else {
199 "text"
200 };
201 (type_, true, filter)
202 }
203 }
204}
205
206/// Normalize a user-supplied widget name to a known token.
207fn normalize_widget(w: &str) -> Option<&'static str> {
208 match w.trim().to_ascii_lowercase().as_str() {
209 "none" => Some("none"),
210 "text" => Some("text"),
211 "enum" => Some("enum"),
212 "date" => Some("date"),
213 "number" => Some("number"),
214 "boolean" => Some("boolean"),
215 _ => None,
216 }
217}
218
219/// Build the interactive payload JSON string, safe to embed inside a
220/// `<script type="application/json">` element. Every `<` is escaped to its JSON
221/// `<` form: this neutralizes `</script`, `<!--`, and `<script` sequences —
222/// all of which can otherwise steer the HTML tokenizer's script-data states and
223/// prevent the element from closing (escaping only `</` is NOT sufficient because
224/// `<!--<script>` contains no `</`). `<` is valid JSON and `JSON.parse`
225/// restores it to `<`, so the island sees the original strings.
226pub(crate) fn build_payload(
227 view: &View,
228 base: &BaseFile,
229 columns: &[String],
230 rows: &[RowView],
231 _opts: &RenderOptions,
232) -> String {
233 let overrides = view.interactive.as_ref();
234 let max_enum = overrides
235 .and_then(|iv| iv.max_enum)
236 .unwrap_or(DEFAULT_MAX_ENUM);
237
238 // columns[]
239 let cols_json: Vec<J> = columns
240 .iter()
241 .map(|key| {
242 let meta = infer_column(key, rows, max_enum, overrides);
243 json!({
244 "key": key,
245 "header": column_header(key, base),
246 "type": meta.type_,
247 "sortable": meta.sortable,
248 "filter": meta.filter,
249 })
250 })
251 .collect();
252
253 // Which columns order as versions. Decided per column (not per sort key)
254 // because the island can sort by any sortable column, and once per column
255 // rather than per cell because detection reads every value.
256 let semver_cols: BTreeSet<&String> = columns
257 .iter()
258 .filter(|key| {
259 render::sorts_as_semver(
260 view,
261 key,
262 rows.iter()
263 .map(|r| r.cells.get(*key).unwrap_or(&Value::Null)),
264 )
265 })
266 .collect();
267
268 // rows[]
269 let rows_json: Vec<J> = rows
270 .iter()
271 .map(|r| {
272 let mut cells = Map::new();
273 for key in columns {
274 let v = r.cells.get(key).cloned().unwrap_or(Value::Null);
275 cells.insert(key.clone(), project_cell(&v, semver_cols.contains(key)));
276 }
277 json!({ "id": r.id, "cells": J::Object(cells) })
278 })
279 .collect();
280
281 // view.groupBy — table-only. `render_cards`/`render_list` never consult
282 // `group_by` (a grouped cards/list view renders ungrouped by design), so
283 // reporting it here would be a lie about the DOM: the island reads this field
284 // to set `V.grouped`, and a grouped view suppresses the sort dropdown that is
285 // the ONLY sort affordance cards/list have — leaving an ungrouped view the
286 // reader cannot sort.
287 let group_by = view
288 .group_by
289 .as_ref()
290 .filter(|_| view.view_type == "table")
291 .map(|gb| json!({ "col": gb.property(), "desc": gb.descending() }));
292
293 let key_json = |keys: &[SortKey]| -> Vec<J> {
294 keys.iter()
295 .map(|k| json!({ "col": k.property(), "desc": k.descending() }))
296 .collect()
297 };
298
299 // view.sort — the order the SSR rows are ACTUALLY in. `apply_sort` reads only
300 // `view.sort` (and early-returns when it is empty, leaving corpus order), so
301 // this is not interchangeable with `controls.sort` below: the island compares
302 // the two to decide whether the DOM needs reordering on first render, and
303 // conflating them made a `defaultSort` silently never apply.
304 let ssr_sort_json = key_json(&view.sort);
305
306 // controls.sort — what the controls open on. `defaultSort` overrides
307 // `view.sort` here, and ONLY here; it is an interactive-time initial sort and
308 // deliberately does not move the static build's rows.
309 let sort_keys = overrides
310 .filter(|iv| !iv.default_sort.is_empty())
311 .map(|iv| iv.default_sort.as_slice())
312 .unwrap_or(view.sort.as_slice());
313 let sort_json: Vec<J> = key_json(sort_keys);
314
315 // controls.search — override else default true.
316 let search = overrides.and_then(|iv| iv.search).unwrap_or(true);
317
318 // controls.pageSize — override → default 50 when >50 rows else 0. Deliberately
319 // does NOT fall back to `view.limit`: that is a row cap the renderer has
320 // already applied, not a page size (see render_view). `rows` here is the
321 // post-limit set, so the default keys off what the reader will actually get.
322 let page_size = overrides
323 .and_then(|iv| iv.page_size)
324 .unwrap_or(if rows.len() > 50 { 50 } else { 0 });
325
326 let payload = json!({
327 "v": 1,
328 "view": {
329 "type": view.view_type,
330 "name": view.name,
331 "groupBy": group_by,
332 "sort": ssr_sort_json,
333 // No `limit`: it is applied server-side, so `rows` here is already the
334 // capped set. Shipping it would invite the island to cap a second time.
335 },
336 "columns": cols_json,
337 "rows": rows_json,
338 "controls": {
339 "search": search,
340 "sort": sort_json,
341 "pageSize": page_size,
342 },
343 });
344
345 // serde_json never fails to serialize a Value. Escape EVERY `<` (not just
346 // `</`) so no `</script`/`<!--`/`<script` sequence can escape the enclosing
347 // <script> element; `<` is valid JSON and parses back to `<`.
348 serde_json::to_string(&payload)
349 .unwrap_or_else(|_| "{}".into())
350 .replace('<', "\\u003c")
351}