use super::nodes::apply_declared_types;
use super::{count_nodes, EdgeGroups};
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain;
use crate::graph::DirGraph;
use crate::okf::model::{BuildOptions, BuildReport, ConceptDoc};
use crate::okf::structure::profile::render_embed_text;
use crate::okf::structure::StructureProfile;
use std::collections::{BTreeMap, BTreeSet, HashMap};
const EMBED_TEXT_PROPERTY: &str = "embed_text";
#[derive(Debug, Default)]
pub(super) struct DerivedIndex {
notes: HashMap<String, NoteAnchors>,
sections_declared: bool,
}
#[derive(Debug, Default)]
struct NoteAnchors {
by_suffix: HashMap<String, String>,
by_lower_suffix: HashMap<String, String>,
by_title: HashMap<String, String>,
by_lower_title: HashMap<String, String>,
}
impl DerivedIndex {
pub(super) fn retarget(&self, note_id: &str, anchor: &str) -> Option<(String, String)> {
let anchors = self.notes.get(note_id)?;
let suffix = format!("#{anchor}");
let suffix = if anchors.by_suffix.contains_key(&suffix) {
suffix
} else {
anchors
.by_lower_suffix
.get(&suffix.to_lowercase())
.or_else(|| anchors.by_title.get(anchor))
.or_else(|| anchors.by_lower_title.get(&anchor.to_lowercase()))
.cloned()?
};
let label = anchors.by_suffix.get(&suffix)?;
Some((format!("{note_id}{suffix}"), label.clone()))
}
pub(super) fn sections_declared(&self) -> bool {
self.sections_declared
}
}
pub(super) fn build_structure(
graph: &mut DirGraph,
docs: &[ConceptDoc],
opts: &BuildOptions,
declared_types: Option<&BTreeMap<String, BTreeMap<String, String>>>,
unmatched: &mut BTreeSet<(String, String)>,
report: &mut BuildReport,
) -> Result<(EdgeGroups, DerivedIndex), String> {
let Some(profile) = &opts.profile.structure else {
return Ok((EdgeGroups::new(), DerivedIndex::default()));
};
let mut index = DerivedIndex {
sections_declared: profile.sections.is_some(),
..DerivedIndex::default()
};
let section_labels: Vec<&str> = profile
.sections
.as_ref()
.map(|rule| rule.label.as_str())
.into_iter()
.chain(profile.key_from_heading.as_ref().map(|r| r.label.as_str()))
.collect();
let mut rows_by_label: BTreeMap<String, Vec<Row>> = BTreeMap::new();
let mut groups = EdgeGroups::new();
for doc in docs {
report.forced_splits += doc.derived.forced_splits;
if doc.derived.nodes.is_empty() {
continue;
}
let anchors = index.notes.entry(doc.concept_id.clone()).or_default();
for node in &doc.derived.nodes {
anchors
.by_suffix
.insert(node.suffix.clone(), node.label.clone());
anchors
.by_lower_suffix
.entry(node.suffix.to_lowercase())
.or_insert_with(|| node.suffix.clone());
let is_section = section_labels.iter().any(|label| *label == node.label);
if let (true, Some(Value::String(title))) = (is_section, property(node, "title")) {
anchors
.by_title
.entry(title.clone())
.or_insert_with(|| node.suffix.clone());
anchors
.by_lower_title
.entry(title.to_lowercase())
.or_insert_with(|| node.suffix.clone());
}
rows_by_label
.entry(node.label.clone())
.or_default()
.push(row_for(doc, node, profile));
}
collect_edges(doc, &mut groups);
}
emit_nodes(graph, rows_by_label, declared_types, unmatched, report)?;
let edge_tables_hit: BTreeSet<&str> = docs
.iter()
.flat_map(|doc| doc.derived.edge_tables_hit.iter().map(String::as_str))
.collect();
warn_unmatched_rules(profile, &edge_tables_hit, report);
Ok((groups, index))
}
type Row = Vec<(String, Value)>;
fn property<'a>(node: &'a crate::okf::structure::DerivedNode, name: &str) -> Option<&'a Value> {
node.props.iter().find(|(k, _)| k == name).map(|(_, v)| v)
}
fn row_for(
doc: &ConceptDoc,
node: &crate::okf::structure::DerivedNode,
profile: &StructureProfile,
) -> Row {
let id = format!("{}{}", doc.concept_id, node.suffix);
let mut row: Row = vec![("concept_id".to_string(), Value::String(id.clone()))];
row.extend(node.props.iter().cloned());
row.push(("note_id".to_string(), Value::String(doc.concept_id.clone())));
if let Some(section) = &node.section {
row.push((
"section_id".to_string(),
Value::String(format!("{}{section}", doc.concept_id)),
));
}
for name in &profile.inherit {
if let Some((_, value)) = doc.props.iter().find(|(k, _)| k == name) {
row.push((name.clone(), value.clone()));
}
}
if let Some(text) = &node.text {
if let Some(template) = &profile.embed_text {
row.push((
EMBED_TEXT_PROPERTY.to_string(),
Value::String(render_embed_text(
template,
&doc.title,
node.section_title.as_deref().unwrap_or_default(),
&node.heading_path,
text,
&id,
)),
));
}
row.push(("text".to_string(), Value::String(text.clone())));
}
row
}
fn collect_edges(doc: &ConceptDoc, groups: &mut EdgeGroups) {
let labels: HashMap<&str, &str> = doc
.derived
.nodes
.iter()
.map(|n| (n.suffix.as_str(), n.label.as_str()))
.collect();
let endpoint = |suffix: &Option<String>| match suffix {
Some(suffix) => (
format!("{}{suffix}", doc.concept_id),
labels
.get(suffix.as_str())
.copied()
.unwrap_or("")
.to_string(),
),
None => (doc.concept_id.clone(), doc.label.clone()),
};
for edge in &doc.derived.edges {
let (source_id, source_label) = endpoint(&edge.source);
let (target_id, target_label) = endpoint(&Some(edge.target.clone()));
groups
.entry((edge.conn_type.clone(), source_label, target_label))
.or_default()
.push((source_id, target_id, Vec::new()));
}
}
fn emit_nodes(
graph: &mut DirGraph,
rows_by_label: BTreeMap<String, Vec<Row>>,
declared_types: Option<&BTreeMap<String, BTreeMap<String, String>>>,
unmatched: &mut BTreeSet<(String, String)>,
report: &mut BuildReport,
) -> Result<(), String> {
for (label, rows) in rows_by_label {
count_nodes(report, &label, rows.len());
let mut columns: Vec<String> = rows
.iter()
.flat_map(|row| row.iter().map(|(k, _)| k.clone()))
.collect::<BTreeSet<String>>()
.into_iter()
.collect();
columns.sort_by_key(|c| (c != "concept_id", c.clone()));
let declared: BTreeMap<&str, &str> = declared_types
.and_then(|t| t.get(&label))
.into_iter()
.flatten()
.filter(|(property, _)| property.as_str() != "concept_id")
.map(|(property, keyword)| (property.as_str(), keyword.as_str()))
.collect();
for property in declared.keys() {
unmatched.remove(&(label.clone(), (*property).to_string()));
}
unmatched.remove(&(label.clone(), "concept_id".to_string()));
let has_title = columns.iter().any(|c| c == "title");
let mut frame_rows = Vec::with_capacity(rows.len());
for row in &rows {
let mut values: Vec<Value> = columns
.iter()
.map(|column| {
row.iter()
.find(|(k, _)| k == column)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Null)
})
.collect();
if !declared.is_empty() {
let id = match &values[0] {
Value::String(id) => id.clone(),
other => crate::datatypes::values::raw_string(other),
};
apply_declared_types(&mut values, &columns, &declared, &label, &id, report);
}
frame_rows.push(values);
}
let df = DataFrame::from_cypher_rows(columns, frame_rows)?;
maintain::add_nodes(
graph,
df,
label.clone(),
"concept_id".to_string(),
has_title.then(|| "title".to_string()),
Some("update".to_string()),
)?;
}
Ok(())
}
fn warn_unmatched_rules(
profile: &StructureProfile,
edge_tables_hit: &BTreeSet<&str>,
report: &mut BuildReport,
) {
let labels = [
profile.sections.as_ref().map(|r| r.label.as_str()),
profile.chunks.as_ref().map(|r| r.label.as_str()),
profile.callouts.as_ref().map(|r| r.label.as_str()),
profile.code_fences.as_ref().map(|r| r.label.as_str()),
profile.ordered_lists.as_ref().map(|r| r.label.as_str()),
profile.key_from_heading.as_ref().map(|r| r.label.as_str()),
];
let rows = profile
.tables
.iter()
.filter(|rule| !rule.edges)
.map(|rule| rule.label.as_deref());
for label in labels.into_iter().chain(rows).flatten() {
if !report.nodes_by_label.contains_key(label) {
report.warnings.push(format!(
"`vault.yaml` declares a `structure:` rule for `{label}`, but no note's \
body produced one"
));
}
}
for rule in profile.tables.iter().filter(|rule| rule.edges) {
if !edge_tables_hit.contains(rule.edge.as_str()) {
report.warnings.push(format!(
"`vault.yaml` declares an edge table for `{}`, but no note's body \
produced one",
rule.edge
));
}
}
}
#[cfg(test)]
#[path = "structure_tests.rs"]
mod structure_tests;