Skip to main content

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}