use super::{LinkIndex, Note, Target};
use crate::datatypes::values::{raw_string, Value};
use crate::okf::structure::block::BlockKind;
use crate::okf::structure::parse_blocks;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
const TARGET_COLUMN: &str = "target";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Row {
target: String,
anchor: Option<String>,
label: Option<String>,
order: Option<i64>,
props: BTreeMap<String, String>,
}
impl Row {
pub(super) fn new(link: String, props: &[(String, Value)]) -> Row {
let mut row = Row {
target: link,
anchor: None,
label: None,
order: None,
props: BTreeMap::new(),
};
for (name, value) in props {
match name.as_str() {
"section" => {}
"anchor" => row.anchor = text(value),
"label" => row.label = text(value),
"row" => row.order = as_int(value),
_ => {
row.props.insert(name.clone(), string_of(value));
}
}
}
row
}
fn target_cell(&self) -> String {
let mut out = format!("[[{}", self.target);
if let Some(anchor) = &self.anchor {
out.push('#');
out.push_str(anchor);
}
if let Some(label) = &self.label {
out.push('|');
out.push_str(label);
}
out.push_str("]]");
out
}
fn sort_key(&self) -> (i64, String, String) {
(
self.order.unwrap_or(i64::MAX),
self.target_cell(),
self.props
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\u{1f}"),
)
}
}
fn text(value: &Value) -> Option<String> {
match value {
Value::String(s) if s.is_empty() => None,
Value::Null => None,
other => Some(string_of(other)),
}
}
fn as_int(value: &Value) -> Option<i64> {
match value {
Value::Int64(n) => Some(*n),
_ => None,
}
}
fn string_of(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => raw_string(other),
}
}
#[derive(Debug, PartialEq, Eq)]
enum Placement {
Replace(Range<usize>, String),
Insert(usize),
Append,
}
fn locate(body: &str, heading: &str) -> Placement {
let tree = parse_blocks(body);
let wanted = heading.trim();
let Some(at) = tree.headings.iter().position(|h| h.text.trim() == wanted) else {
return Placement::Append;
};
let table = tree
.blocks
.iter()
.find(|b| b.heading == Some(at) && matches!(b.kind, BlockKind::Table(_)));
match table {
Some(block) => {
let first = match &block.kind {
BlockKind::Table(table) => table
.header
.first()
.map(|cell| cell.text.clone())
.unwrap_or_default(),
_ => String::new(),
};
Placement::Replace(block.range.clone(), first)
}
None => Placement::Insert(
tree.headings
.get(at + 1)
.map(|next| next.range.start)
.unwrap_or(body.len()),
),
}
}
pub(super) fn prose_outside_owned_tables(body: &str, headings: &[&str]) -> String {
let mut ranges: Vec<Range<usize>> = headings
.iter()
.filter_map(|heading| match locate(body, heading) {
Placement::Replace(range, _) => Some(range),
Placement::Insert(_) | Placement::Append => None,
})
.collect();
if ranges.is_empty() {
return body.to_string();
}
ranges.sort_by_key(|r| r.start);
let mut out = String::with_capacity(body.len());
let mut at = 0usize;
for range in ranges {
if range.start >= at {
out.push_str(&body[at..range.start]);
at = range.end;
}
}
out.push_str(&body[at..]);
out
}
pub(super) fn apply(body: &str, tables: &[(&str, Vec<Row>)]) -> String {
let mut out = body.to_string();
for (heading, rows) in tables {
out = write_one(&out, heading, rows);
}
out
}
fn write_one(body: &str, heading: &str, rows: &[Row]) -> String {
let placement = locate(body, heading);
if rows.is_empty() {
return match placement {
Placement::Replace(range, _) => cut(body, range),
Placement::Insert(_) | Placement::Append => body.to_string(),
};
}
match placement {
Placement::Replace(range, first) => {
let mut table = render(&first, rows);
if body[range.clone()].ends_with('\n') {
table.push('\n');
}
format!("{}{table}{}", &body[..range.start], &body[range.end..])
}
Placement::Insert(at) => splice(body, at, &render(TARGET_COLUMN, rows)),
Placement::Append => splice(
body,
body.len(),
&format!("## {heading}\n\n{}", render(TARGET_COLUMN, rows)),
),
}
}
fn render(first_column: &str, rows: &[Row]) -> String {
let mut sorted: Vec<&Row> = rows.iter().collect();
sorted.sort_by_key(|row| row.sort_key());
let columns: Vec<&str> = sorted
.iter()
.flat_map(|row| row.props.keys())
.map(String::as_str)
.collect::<BTreeSet<&str>>()
.into_iter()
.collect();
let mut out = String::new();
out.push_str(&line(
std::iter::once(escape(first_column)).chain(columns.iter().map(|name| escape(name))),
));
out.push_str(&line(std::iter::repeat_n(
"---".to_string(),
columns.len() + 1,
)));
for row in sorted {
out.push_str(&line(std::iter::once(escape(&row.target_cell())).chain(
columns.iter().map(|name| {
row.props
.get(*name)
.map(|value| escape(value))
.unwrap_or_default()
}),
)));
}
out.pop();
out
}
fn line(cells: impl Iterator<Item = String>) -> String {
let mut out = String::from("|");
for cell in cells {
out.push(' ');
out.push_str(&cell);
out.push_str(" |");
}
out.push('\n');
out
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'|' => out.push_str("\\|"),
'\n' | '\r' => out.push(' '),
other => out.push(other),
}
}
out.trim().to_string()
}
fn splice(body: &str, at: usize, block: &str) -> String {
let (before, after) = body.split_at(at);
let mut out = before.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(block.trim_end());
let rest = after.trim_start_matches('\n');
if rest.is_empty() {
out.push('\n');
} else {
out.push_str("\n\n");
out.push_str(rest);
}
out
}
fn cut(body: &str, range: Range<usize>) -> String {
let before = body[..range.start].trim_end();
let after = body[range.end..].trim_start_matches('\n');
if before.is_empty() {
return after.to_string();
}
if after.is_empty() {
return format!("{before}\n");
}
format!("{before}\n\n{after}")
}
pub(super) fn rows_for(
edges: &[super::OutEdge],
conn_type: &str,
notes: &[Note],
index: &LinkIndex,
skip: impl Fn(&super::OutEdge) -> bool,
) -> Vec<Row> {
edges
.iter()
.filter(|edge| edge.conn_type == conn_type && !skip(edge))
.map(|edge| {
let link = match &edge.target {
Target::Note(at) => index.wikilink(¬es[*at]),
Target::Stub(name) => name.clone(),
};
Row::new(link, &edge.props)
})
.collect()
}
#[cfg(test)]
#[path = "edge_tables_tests.rs"]
mod edge_tables_tests;