Make edge modulation steps more consistent

This commit is contained in:
Juno Takano 2026-01-12 14:34:55 -03:00
commit bd5d46a5d4
8 changed files with 227 additions and 118 deletions

View file

@ -3,6 +3,7 @@ use std::collections::HashMap;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::syntax::content; use crate::syntax::content;
use crate::prelude::*;
pub use { pub use {
node::Node, node::Node,
edge::Edge, edge::Edge,
@ -25,10 +26,23 @@ pub struct Graph {
pub meta: Meta, pub meta: Meta,
} }
#[derive(Clone)] #[derive(Clone, Default, Debug)]
pub struct QueryResult { pub struct QueryResult {
pub node: Option<Node>, pub node: Option<Node>,
pub redirect: bool, 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 { 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 { pub fn find_node(&self, query: &str) -> QueryResult {
let collapsed_query = query.trim().replace(" ", ""); 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) { let candidate = if let Some(exact_match) = self.nodes.get(query) {
log!("Elected exact match {exact_match}");
QueryResult { QueryResult {
node: Some(exact_match.clone()), node: Some(exact_match.clone()),
exact: true,
redirect: false, redirect: false,
} }
} else if let Some(lower_key) = } else if let Some(lower_key) =
self.lowercase_keymap.get(&collapsed_query.to_lowercase()) self.lowercase_keymap.get(&collapsed_query.to_lowercase())
{ {
log!("Elected non-exact match through lower key {lower_key}");
QueryResult { QueryResult {
node: self.nodes.get(lower_key).cloned(), node: self.nodes.get(lower_key).cloned(),
redirect: true, exact: false,
}
} else {
QueryResult {
node: None,
redirect: false, redirect: false,
} }
} else {
log!("No candidate found");
QueryResult::default()
}; };
if let Some(candidate_node) = &candidate.node if let Some(candidate_node) = &candidate.node
&& !candidate_node.redirect.is_empty() && !candidate_node.redirect.is_empty()
{ {
QueryResult { log!("Recursing: candidate is a redirect");
node: self.find_node(&candidate_node.redirect).node, if let Some(recursive_match) =
redirect: true, self.find_node(&candidate_node.redirect).node
{
QueryResult {
node: Some(recursive_match),
exact: false,
redirect: true,
}
} else {
QueryResult::default()
} }
} else { } else {
log!("Returning candidate {candidate}");
candidate candidate
} }
} }

View file

@ -4,8 +4,6 @@ use serde::{Serialize, Deserialize};
pub struct Edge { pub struct Edge {
pub to: String, pub to: String,
#[serde(default)] #[serde(default)]
pub anchor: String,
#[serde(default)]
pub from: String, pub from: String,
#[serde(default)] #[serde(default)]
pub detached: bool, pub detached: bool,

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use super::edge::Edge; use super::edge::Edge;
@ -20,7 +22,7 @@ pub struct Node {
pub hidden: bool, pub hidden: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub connections: Option<Vec<Edge>>, pub connections: Option<HashMap<String, Edge>>,
} }
impl Node { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1,11 +1,13 @@
use axum::response::IntoResponse as _; use axum::response::IntoResponse as _;
use axum::{body::Body, extract::Path, http::Response, response::Redirect}; use axum::{body::Body, extract::Path, http::Response, response::Redirect};
use crate::graph::Edge;
use crate::{syntax::serial::populate_graph, router::handlers, graph::Node}; use crate::{syntax::serial::populate_graph, router::handlers, graph::Node};
pub async fn node(Path(id): Path<String>) -> Response<Body> { pub async fn node(Path(id): Path<String>) -> Response<Body> {
let graph = populate_graph(); let graph = populate_graph();
let result = graph.find_node(&id); let result = graph.find_node(&id);
let found = result.node.is_some();
let nodes: Vec<Node> = graph.nodes.clone().into_values().collect(); let nodes: Vec<Node> = graph.nodes.clone().into_values().collect();
let not_found = result.node.is_none(); let not_found = result.node.is_none();
let node = result let node = result
@ -19,7 +21,7 @@ pub async fn node(Path(id): Path<String>) -> Response<Body> {
.into_response(); .into_response();
} }
if result.redirect { if found && !result.exact {
return Redirect::permanent(format!("/node/{}", node.id).as_str()) return Redirect::permanent(format!("/node/{}", node.id).as_str())
.into_response(); .into_response();
} }
@ -27,6 +29,15 @@ pub async fn node(Path(id): Path<String>) -> Response<Body> {
let mut context = tera::Context::default(); let mut context = tera::Context::default();
context.insert("node", &node); context.insert("node", &node);
context.insert("nodes", &nodes); context.insert("nodes", &nodes);
context.insert(
"connections",
&node
.connections
.clone()
.unwrap_or_default()
.values()
.collect::<Vec<&Edge>>(),
);
context.insert("incoming", &graph.incoming.get(&id)); context.insert("incoming", &graph.incoming.get(&id));
context.insert("config", &graph.meta.config); context.insert("config", &graph.meta.config);

View file

@ -18,18 +18,18 @@ pub fn populate_graph() -> Graph {
Err(e) => format!("Error: {e}"), Err(e) => format!("Error: {e}"),
}; };
let graph = deserialize_graph(&Format::TOML, &toml_source); let mut graph = deserialize_graph(&Format::TOML, &toml_source);
modulate_graph(&graph) 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 nodes = modulate_nodes(in_graph);
let mut graph = Graph { let mut graph = Graph {
incoming: make_incoming(&nodes), incoming: make_incoming(&nodes),
lowercase_keymap: map_lowercase_keys(&nodes),
nodes, nodes,
..in_graph.to_owned() ..in_graph.clone()
}; };
graph.parse(); graph.parse();
@ -37,65 +37,42 @@ fn modulate_graph(in_graph: &Graph) -> Graph {
} }
fn modulate_nodes(graph: &Graph) -> HashMap<String, Node> { fn modulate_nodes(graph: &Graph) -> HashMap<String, Node> {
let old_nodes = graph.nodes.clone(); let in_nodes = graph.nodes.clone();
let mut nodes: HashMap<String, Node> = HashMap::default();
for (key, node) in old_nodes.clone() { let mut first_pass_nodes: HashMap<String, Node> = HashMap::default();
for (key, node) in in_nodes.clone() {
let connections = node.connections.clone().unwrap_or_default(); let connections = node.connections.clone().unwrap_or_default();
let mut new_edges = connections.clone(); let mut new_edges = connections.clone();
// Parse node text
let (text, tokens) = content::rich_parse(&node.text, graph);
// Modulate connections // Modulate connections
for (i, edge) in connections.iter().enumerate() { for (connection_key, edge) in connections {
let mut new_edge = edge.clone(); let mut new_edge = edge.clone();
// Populate empty "from" IDs in edges with node's ID // Populate empty "from" IDs in edges with node's ID
if edge.from.is_empty() { if edge.from.is_empty() {
new_edge.from.clone_from(&key); new_edge.from.clone_from(&connection_key);
} }
// Flag detached edges // Flag detached edges
if !old_nodes.contains_key(&edge.to) { if !in_nodes.contains_key(&edge.to) {
new_edge.detached = true; 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; *e = new_edge;
} }
} }
// Create connections for each link // Create connections for each link
for link in &node.links { for link in &node.links {
new_edges.push(Edge { new_edges.insert(
from: key.clone(), link.clone(),
to: link.clone(), Edge {
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<Anchor> = 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 {
from: key.clone(), from: key.clone(),
to: anchor_node.id, to: link.clone(),
anchor: anchor.text(), detached: !in_nodes.clone().contains_key(link),
detached: false, },
}); );
}
} }
// Populate empty titles with IDs // Populate empty titles with IDs
@ -131,19 +108,64 @@ fn modulate_nodes(graph: &Graph) -> HashMap<String, Node> {
node.summary.clone() node.summary.clone()
}; };
// Assemble new node
let new_node = Node { let new_node = Node {
id: key.clone(), id: key.clone(),
title: new_title, title: new_title,
summary: flatten(&summary, graph), summary: flatten(&summary, graph),
connections: Some(new_edges),
..node.clone()
};
first_pass_nodes.insert(key.clone(), new_node);
}
let mut second_pass_nodes: HashMap<String, Node> = 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<Anchor> = 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), connections: Some(new_edges),
text, text,
..node.clone() ..node.clone()
}; };
nodes.insert(key.clone(), new_node); second_pass_nodes.insert(key.clone(), new_node);
} }
nodes second_pass_nodes
} }
pub enum Format { pub enum Format {
@ -183,7 +205,7 @@ fn make_incoming(nodes: &HashMap<String, Node>) -> HashMap<String, Vec<Edge>> {
for node in nodes.clone().into_values() { for node in nodes.clone().into_values() {
let empty_vec: Vec<Edge> = vec![]; let empty_vec: Vec<Edge> = vec![];
for edge in &node.connections.clone().unwrap_or_default() { for edge in node.connections.clone().unwrap_or_default().values() {
let mut edges = let mut edges =
incoming.get(&edge.to.clone()).unwrap_or(&empty_vec).clone(); incoming.get(&edge.to.clone()).unwrap_or(&empty_vec).clone();
edges.extend_from_slice(std::slice::from_ref(edge)); edges.extend_from_slice(std::slice::from_ref(edge));
@ -194,17 +216,6 @@ fn make_incoming(nodes: &HashMap<String, Node>) -> HashMap<String, Vec<Edge>> {
incoming incoming
} }
fn map_lowercase_keys(
source_map: &HashMap<String, Node>,
) -> HashMap<String, String> {
let mut out_map: HashMap<String, String> = HashMap::default();
let keys = source_map.keys();
for key in keys {
out_map.insert(key.clone().to_lowercase(), key.clone());
}
out_map
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -220,7 +231,7 @@ mod tests {
"links": [], "links": [],
"id": "JSON", "id": "JSON",
"hidden": false, "hidden": false,
"connections": [] "connections": {}
} }
}, },
"root_node": "JSON" "root_node": "JSON"

View file

@ -484,10 +484,6 @@ Because en is defined in simple configuration files, you can add new pages easil
links = [ "Graph" ] links = [ "Graph" ]
[[nodes.en.connections]]
to = "TOML"
anchor = "TOML"
[nodes.Graph] [nodes.Graph]
text = """ text = """
A graph is a data structure composed of connected (and disconnected) nodes. 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] [nodes.Roadmap]
text = """ text = """
- [x] Formatting - [ ] Performance
- [ ] Blockquotes - [ ] Caching
- [x] Nested formatting - [ ] Move more logic from Serial to Graph submodules
- [x] Headers - [ ] Further centralize state
- [x] Preformatted blocks - [ ] Reduce O(n) calls in the formats module
- [x] _Oblique_, - [ ] Input syntax
- [x] __Underline__ - [ ] Invert where redirects are set
- [x] ~~Strikethrough~~ - [x] Formatting
- [x] *Bold* - [ ] Blockquotes
- [x] `Inline code` - [x] Nested formatting
- [x] Lists - [x] Headers
- [x] Nested lists - [x] Preformatted blocks
- [x] Checkboxes - [x] _Oblique_,
- [x] Move this roadmap to en - [x] __Underline__
- [ ] Caching - [x] ~~Strikethrough~~
- [ ] Invert where redirects are set - [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 - [ ] Meta-awareness
- [ ] Detached edges - [ ] Detached edges
- [ ] Most linked to nodes - [ ] Most linked to nodes
- [ ] Most linked from nodes - [ ] Most linked from nodes
- [ ] Most linked to nonexistent nodes - [ ] Most linked to nonexistent nodes
- [ ] Most linked - [ ] Most linked
- [ ] Special sections - [ ] Rendering
- [ ] Definition (implies metadata `has_definition`) - [ ] Sorting of tree, index list and drop-down navigation
- [ ] See also (implies a kind of connection, e.g. `related`) - [ ] Alphabetic
- [ ] Not to be confused with (implies a kind of connection) - [ ] By most linked to
- [ ] Contrast with (implies a kind of connection) - [ ] By most linked
- [ ] Example (implies metadata `has_example`) - [ ] Themes
- [ ] References/influences (implies metadata `has_references`)
- [ ] Sorting of tree, index list and drop-down navigation
- [ ] Alphabetic
- [ ] By most linked to
- [ ] By most linked
- [x] Anchors and connections - [x] Anchors and connections
- [x] Render detached anchors differently - [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 - [ ] Suffix-aware anchors
- [x] Plural anchors (`|node|s` -> `node`) - [x] Plural anchors (`|node|s` -> `node`)
- [x] Ignore trailing punctuation - [x] Ignore trailing punctuation
@ -572,17 +575,16 @@ text = """
- [ ] Specialization <-> Generalization - [ ] Specialization <-> Generalization
- [ ] Custom connection kinds - [ ] Custom connection kinds
- [x] External anchors - [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 - [ ] 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 ## Done
@ -604,3 +606,14 @@ navbar_search = true
footer_text = """ footer_text = """
made by jutty|https://jutty.dev acknowledgments|Acknowledgments |source code|https://codeberg.org/jutty/en 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|
"""

View file

@ -13,20 +13,20 @@
</div> </div>
{{ node.text | safe }} {{ node.text | safe }}
</section> </section>
{% if node.connections or incoming %} {% if connections or incoming %}
<aside> <aside>
<hr> <hr>
<h2>Connections</h2> <h2>Connections</h2>
{% if node.connections %} {% if connections %}
<ul> <ul>
{% for connection in node.connections | filter(attribute="detached", value=false) %} {% for connection in connections | filter(attribute="detached", value=false) %}
<li> <li>
<strong>{{node.id}}</strong> <strong>{{node.id}}</strong>
&raquo; &raquo;
<a href="/node/{{connection.to}}">{{connection.to}}</a> <a href="/node/{{connection.to}}">{{connection.to}}</a>
</li> </li>
{% endfor %} {% endfor %}
{% for connection in node.connections | filter(attribute="detached", value=true) %} {% for connection in connections | filter(attribute="detached", value=true) %}
<li> <li>
<strong>{{node.id}}</strong> <strong>{{node.id}}</strong>
&raquo; &raquo;

View file

@ -25,7 +25,7 @@
{% if root_node.connections %} {% if root_node.connections %}
{% if config.tree_node_summary %}<li><strong>Connections</strong> {% if config.tree_node_summary %}<li><strong>Connections</strong>
<ul>{% endif %} <ul>{% endif %}
{% for connection in root_node.connections %} {% for _, connection in root_node.connections %}
<li><a href="/node/{{connection.to}}">{{connection.to}}</a></li> <li><a href="/node/{{connection.to}}">{{connection.to}}</a></li>
{% endfor %} {% endfor %}
{% if config.tree_node_summary %}</ul> {% if config.tree_node_summary %}</ul>
@ -53,7 +53,7 @@
{% if node.connections %} {% if node.connections %}
{% if config.tree_node_summary %}<li><strong>Connections</strong> {% if config.tree_node_summary %}<li><strong>Connections</strong>
<ul>{% endif %} <ul>{% endif %}
{% for connection in node.connections %} {% for _, connection in node.connections %}
{% if not connection.detached %} {% if not connection.detached %}
<li><a href="/node/{{connection.to}}">{{connection.to}}</a></li> <li><a href="/node/{{connection.to}}">{{connection.to}}</a></li>
{% endif %} {% endif %}