From bd5d46a5d4e31f071fc7a5f77b76b099ac87229d Mon Sep 17 00:00:00 2001 From: jutty Date: Mon, 12 Jan 2026 14:34:55 -0300 Subject: [PATCH] Make edge modulation steps more consistent --- src/graph.rs | 58 +++++++++++++--- src/graph/edge.rs | 2 - src/graph/node.rs | 38 ++++++++++- src/router/handlers/graph.rs | 13 +++- src/syntax/serial.rs | 125 +++++++++++++++++++---------------- static/graph.toml | 97 +++++++++++++++------------ templates/node.html | 8 +-- templates/tree.html | 4 +- 8 files changed, 227 insertions(+), 118 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index d3f0031..c0b84a0 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use serde::{Serialize, Deserialize}; use crate::syntax::content; +use crate::prelude::*; pub use { node::Node, edge::Edge, @@ -25,10 +26,23 @@ pub struct Graph { pub meta: Meta, } -#[derive(Clone)] +#[derive(Clone, Default, Debug)] pub struct QueryResult { pub node: Option, pub redirect: bool, + pub exact: bool, +} + +impl std::fmt::Display for QueryResult { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let meta = if self.redirect { "[redirect] " } else { "" }; + let node = if let Some(n) = &self.node { + n.id.clone() + } else { + String::from("No Match") + }; + write!(f, "QueryResult: {meta}{node}") + } } impl Graph { @@ -46,36 +60,62 @@ impl Graph { } } + pub fn map_lowercase_keys(&mut self) { + for key in self.nodes.keys() { + self.lowercase_keymap + .insert(key.clone().to_lowercase(), key.clone()); + } + } + pub fn find_node(&self, query: &str) -> QueryResult { let collapsed_query = query.trim().replace(" ", ""); + if query == collapsed_query { + log!("Chasing candidate for query {query}"); + } else { + log!( + "Chasing candidate for query {query}, collapsed {collapsed_query}" + ); + } + let candidate = if let Some(exact_match) = self.nodes.get(query) { + log!("Elected exact match {exact_match}"); QueryResult { node: Some(exact_match.clone()), + exact: true, redirect: false, } } else if let Some(lower_key) = self.lowercase_keymap.get(&collapsed_query.to_lowercase()) { + log!("Elected non-exact match through lower key {lower_key}"); QueryResult { node: self.nodes.get(lower_key).cloned(), - redirect: true, - } - } else { - QueryResult { - node: None, + exact: false, redirect: false, } + } else { + log!("No candidate found"); + QueryResult::default() }; if let Some(candidate_node) = &candidate.node && !candidate_node.redirect.is_empty() { - QueryResult { - node: self.find_node(&candidate_node.redirect).node, - redirect: true, + log!("Recursing: candidate is a redirect"); + if let Some(recursive_match) = + self.find_node(&candidate_node.redirect).node + { + QueryResult { + node: Some(recursive_match), + exact: false, + redirect: true, + } + } else { + QueryResult::default() } } else { + log!("Returning candidate {candidate}"); candidate } } diff --git a/src/graph/edge.rs b/src/graph/edge.rs index 6f5b924..e95e45c 100644 --- a/src/graph/edge.rs +++ b/src/graph/edge.rs @@ -4,8 +4,6 @@ use serde::{Serialize, Deserialize}; pub struct Edge { pub to: String, #[serde(default)] - pub anchor: String, - #[serde(default)] pub from: String, #[serde(default)] pub detached: bool, diff --git a/src/graph/node.rs b/src/graph/node.rs index 3efe26b..b4d8bb9 100644 --- a/src/graph/node.rs +++ b/src/graph/node.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Serialize, Deserialize}; use super::edge::Edge; @@ -20,7 +22,7 @@ pub struct Node { pub hidden: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub connections: Option>, + pub connections: Option>, } impl Node { @@ -41,6 +43,40 @@ impl Node { } } +impl std::fmt::Display for Node { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut meta = String::default(); + if self.title.is_empty() { + meta.push_str("title:none"); + } else { + meta.push_str(&format!("title:'{}'", self.title)); + } + if self.text.is_empty() { + meta.push_str(" text:none"); + } else { + meta.push_str(&format!(" text:{}l", self.text.len())); + } + if self.summary.is_empty() { + meta.push_str(" summary:none"); + } else { + meta.push_str(&format!(" summary:{}", self.summary.len())); + } + if self.redirect.is_empty() { + meta.push_str(" redirect:none"); + } else { + meta.push_str(&format!(" redirect:{}", self.redirect)); + } + let links = self.links.len(); + if links > 0 { + meta.push_str(&format!(" links:{links}")); + } + if self.hidden { + meta.push_str(" hidden"); + } + write!(f, "Node: ID '{}' {meta}", self.id) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/router/handlers/graph.rs b/src/router/handlers/graph.rs index c373034..78facc0 100644 --- a/src/router/handlers/graph.rs +++ b/src/router/handlers/graph.rs @@ -1,11 +1,13 @@ use axum::response::IntoResponse as _; use axum::{body::Body, extract::Path, http::Response, response::Redirect}; +use crate::graph::Edge; use crate::{syntax::serial::populate_graph, router::handlers, graph::Node}; pub async fn node(Path(id): Path) -> Response { let graph = populate_graph(); let result = graph.find_node(&id); + let found = result.node.is_some(); let nodes: Vec = graph.nodes.clone().into_values().collect(); let not_found = result.node.is_none(); let node = result @@ -19,7 +21,7 @@ pub async fn node(Path(id): Path) -> Response { .into_response(); } - if result.redirect { + if found && !result.exact { return Redirect::permanent(format!("/node/{}", node.id).as_str()) .into_response(); } @@ -27,6 +29,15 @@ pub async fn node(Path(id): Path) -> Response { let mut context = tera::Context::default(); context.insert("node", &node); context.insert("nodes", &nodes); + context.insert( + "connections", + &node + .connections + .clone() + .unwrap_or_default() + .values() + .collect::>(), + ); context.insert("incoming", &graph.incoming.get(&id)); context.insert("config", &graph.meta.config); diff --git a/src/syntax/serial.rs b/src/syntax/serial.rs index 0af35a6..77840ac 100644 --- a/src/syntax/serial.rs +++ b/src/syntax/serial.rs @@ -18,18 +18,18 @@ pub fn populate_graph() -> Graph { Err(e) => format!("Error: {e}"), }; - let graph = deserialize_graph(&Format::TOML, &toml_source); - modulate_graph(&graph) + let mut graph = deserialize_graph(&Format::TOML, &toml_source); + modulate_graph(&mut graph) } -fn modulate_graph(in_graph: &Graph) -> Graph { +fn modulate_graph(in_graph: &mut Graph) -> Graph { + in_graph.map_lowercase_keys(); let nodes = modulate_nodes(in_graph); let mut graph = Graph { incoming: make_incoming(&nodes), - lowercase_keymap: map_lowercase_keys(&nodes), nodes, - ..in_graph.to_owned() + ..in_graph.clone() }; graph.parse(); @@ -37,65 +37,42 @@ fn modulate_graph(in_graph: &Graph) -> Graph { } fn modulate_nodes(graph: &Graph) -> HashMap { - let old_nodes = graph.nodes.clone(); - let mut nodes: HashMap = HashMap::default(); + let in_nodes = graph.nodes.clone(); - for (key, node) in old_nodes.clone() { + let mut first_pass_nodes: HashMap = HashMap::default(); + for (key, node) in in_nodes.clone() { let connections = node.connections.clone().unwrap_or_default(); let mut new_edges = connections.clone(); - // Parse node text - let (text, tokens) = content::rich_parse(&node.text, graph); - // Modulate connections - for (i, edge) in connections.iter().enumerate() { + for (connection_key, edge) in connections { let mut new_edge = edge.clone(); // Populate empty "from" IDs in edges with node's ID if edge.from.is_empty() { - new_edge.from.clone_from(&key); + new_edge.from.clone_from(&connection_key); } // Flag detached edges - if !old_nodes.contains_key(&edge.to) { + if !in_nodes.contains_key(&edge.to) { new_edge.detached = true; } - if let Some(e) = new_edges.get_mut(i) { + if let Some(e) = new_edges.get_mut(&connection_key) { *e = new_edge; } } // Create connections for each link for link in &node.links { - new_edges.push(Edge { - from: key.clone(), - to: link.clone(), - anchor: String::default(), - detached: !old_nodes.clone().contains_key(link), - }); - } - - // Create connections for each anchor - let parsed_anchors = - tokens.iter().filter(|t| matches!(t, Token::Anchor(_))); - - let mut anchors: Vec = vec![]; - for anchor in parsed_anchors { - if let Token::Anchor(a) = anchor { - anchors.push(*a.clone()); - } - } - - for anchor in anchors { - if let Some(anchor_node) = anchor.node() { - new_edges.push(Edge { + new_edges.insert( + link.clone(), + Edge { from: key.clone(), - to: anchor_node.id, - anchor: anchor.text(), - detached: false, - }); - } + to: link.clone(), + detached: !in_nodes.clone().contains_key(link), + }, + ); } // Populate empty titles with IDs @@ -131,19 +108,64 @@ fn modulate_nodes(graph: &Graph) -> HashMap { node.summary.clone() }; + // Assemble new node let new_node = Node { id: key.clone(), title: new_title, summary: flatten(&summary, graph), + connections: Some(new_edges), + ..node.clone() + }; + + first_pass_nodes.insert(key.clone(), new_node); + } + + let mut second_pass_nodes: HashMap = HashMap::default(); + for (key, node) in first_pass_nodes.clone() { + let first_pass_graph = Graph { + nodes: first_pass_nodes.clone(), + ..graph.clone() + }; + + // Parse node text + let (text, tokens) = content::rich_parse(&node.text, &first_pass_graph); + + // Create connections for each anchor + let parsed_anchors = + tokens.iter().filter(|t| matches!(t, Token::Anchor(_))); + + let mut anchors: Vec = vec![]; + for anchor in parsed_anchors { + if let Token::Anchor(a) = anchor { + anchors.push(*a.clone()); + } + } + + let mut new_edges = node.connections.clone().unwrap_or_default(); + for anchor in anchors { + if let Some(anchor_node) = anchor.node() { + new_edges.insert( + anchor_node.id.clone(), + Edge { + from: key.clone(), + to: anchor_node.id, + detached: false, + }, + ); + } + } + + // Assemble new node + let new_node = Node { connections: Some(new_edges), text, ..node.clone() }; - nodes.insert(key.clone(), new_node); + second_pass_nodes.insert(key.clone(), new_node); } - nodes + second_pass_nodes } pub enum Format { @@ -183,7 +205,7 @@ fn make_incoming(nodes: &HashMap) -> HashMap> { for node in nodes.clone().into_values() { let empty_vec: Vec = vec![]; - for edge in &node.connections.clone().unwrap_or_default() { + for edge in node.connections.clone().unwrap_or_default().values() { let mut edges = incoming.get(&edge.to.clone()).unwrap_or(&empty_vec).clone(); edges.extend_from_slice(std::slice::from_ref(edge)); @@ -194,17 +216,6 @@ fn make_incoming(nodes: &HashMap) -> HashMap> { incoming } -fn map_lowercase_keys( - source_map: &HashMap, -) -> HashMap { - let mut out_map: HashMap = HashMap::default(); - let keys = source_map.keys(); - for key in keys { - out_map.insert(key.clone().to_lowercase(), key.clone()); - } - out_map -} - #[cfg(test)] mod tests { use super::*; @@ -220,7 +231,7 @@ mod tests { "links": [], "id": "JSON", "hidden": false, - "connections": [] + "connections": {} } }, "root_node": "JSON" diff --git a/static/graph.toml b/static/graph.toml index 5262492..2b7c9a1 100644 --- a/static/graph.toml +++ b/static/graph.toml @@ -484,10 +484,6 @@ Because en is defined in simple configuration files, you can add new pages easil links = [ "Graph" ] -[[nodes.en.connections]] -to = "TOML" -anchor = "TOML" - [nodes.Graph] text = """ A graph is a data structure composed of connected (and disconnected) nodes. @@ -519,42 +515,49 @@ en is only possible thanks to a number of projects and people: [nodes.Roadmap] text = """ -- [x] Formatting - - [ ] Blockquotes - - [x] Nested formatting - - [x] Headers - - [x] Preformatted blocks - - [x] _Oblique_, - - [x] __Underline__ - - [x] ~~Strikethrough~~ - - [x] *Bold* - - [x] `Inline code` - - [x] Lists - - [x] Nested lists - - [x] Checkboxes - - [x] Move this roadmap to en -- [ ] Caching -- [ ] Invert where redirects are set +- [ ] Performance + - [ ] Caching + - [ ] Move more logic from Serial to Graph submodules + - [ ] Further centralize state + - [ ] Reduce O(n) calls in the formats module +- [ ] Input syntax + - [ ] Invert where redirects are set + - [x] Formatting + - [ ] Blockquotes + - [x] Nested formatting + - [x] Headers + - [x] Preformatted blocks + - [x] _Oblique_, + - [x] __Underline__ + - [x] ~~Strikethrough~~ + - [x] *Bold* + - [x] `Inline code` + - [x] Lists + - [x] Nested lists + - [x] Checkboxes + - [x] Move this roadmap to en + - [ ] Special sections + - [ ] Definition (implies metadata `has_definition`) + - [ ] See also (implies a kind of connection, e.g. `related`) + - [ ] Not to be confused with (implies a kind of connection) + - [ ] Contrast with (implies a kind of connection) + - [ ] Example (implies metadata `has_example`) + - [ ] References/influences (implies metadata `has_references`) - [ ] Meta-awareness - [ ] Detached edges - [ ] Most linked to nodes - [ ] Most linked from nodes - [ ] Most linked to nonexistent nodes - [ ] Most linked -- [ ] Special sections - - [ ] Definition (implies metadata `has_definition`) - - [ ] See also (implies a kind of connection, e.g. `related`) - - [ ] Not to be confused with (implies a kind of connection) - - [ ] Contrast with (implies a kind of connection) - - [ ] Example (implies metadata `has_example`) - - [ ] References/influences (implies metadata `has_references`) -- [ ] Sorting of tree, index list and drop-down navigation - - [ ] Alphabetic - - [ ] By most linked to - - [ ] By most linked +- [ ] Rendering + - [ ] Sorting of tree, index list and drop-down navigation + - [ ] Alphabetic + - [ ] By most linked to + - [ ] By most linked + - [ ] Themes - [x] Anchors and connections - [x] Render detached anchors differently - - [ ] Count connection to a redirect as a connection to the target + - [x] Count connection to a redirect as a connection to the target - [ ] Suffix-aware anchors - [x] Plural anchors (`|node|s` -> `node`) - [x] Ignore trailing punctuation @@ -572,17 +575,16 @@ text = """ - [ ] Specialization <-> Generalization - [ ] Custom connection kinds - [x] External anchors +- [ ] I/O formats + - [ ] Output + - [ ] Add separate TOML endpoints for pre/postprocessed + - [ ] Render to filesystem + - [ ] Single-page rendering + - [ ] Input + - [ ] Frontmatter format + - [ ] Multi-file graphs + - [ ] Multi-graph - [ ] Full-text search -- [ ] Begin centralizing state -- [ ] Reduce O(n) calls in the formats module -- [ ] Alternative rendering modes - - [ ] Render to filesystem - - [ ] Single-page rendering -- [ ] Input formats - - [ ] Frontmatter format - - [ ] Multi-file graphs - - [ ] Multi-graph -- [ ] Themes ## Done @@ -604,3 +606,14 @@ navbar_search = true footer_text = """ made by jutty|https://jutty.dev • acknowledgments|Acknowledgments • |source code|https://codeberg.org/jutty/en """ + +[nodes.t0] +text = """ +*t0* is a node linked to |t1| +""" + +[nodes.t1] +text = """ +*t1* is a node linked to |T0| +""" + diff --git a/templates/node.html b/templates/node.html index 8d38268..2a3a353 100644 --- a/templates/node.html +++ b/templates/node.html @@ -13,20 +13,20 @@ {{ node.text | safe }} - {% if node.connections or incoming %} + {% if connections or incoming %}