diff --git a/src/graph.rs b/src/graph.rs index c0b84a0..47361c4 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1,8 +1,14 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::PathBuf}; use serde::{Serialize, Deserialize}; -use crate::syntax::content; +use crate::syntax::{ + command::Arguments, + content::{ + self, + parser::{flatten, Token, token::Anchor}, + }, +}; use crate::prelude::*; pub use { node::Node, @@ -46,17 +52,225 @@ impl std::fmt::Display for QueryResult { } impl Graph { - pub fn new(message: Option<&str>) -> Graph { + pub fn error(message: Option<&str>) -> Graph { + let graph = Graph::default(); Graph { - nodes: HashMap::default(), - root_node: "VoidNode".to_string(), - incoming: HashMap::default(), - lowercase_keymap: HashMap::default(), meta: Meta { - config: Config::default(), - version: (0, 1, 0), messages: message.map_or(vec![], |m| vec![m.to_string()]), + ..graph.meta }, + ..graph + } + } + + /// Loads a TOML file from the default location and returns a modulated Graph + pub fn load() -> Graph { + Self::load_file("") + } + + /// Takes a file path to a TOML file and returns a modulated Graph + /// + /// If `path` is an empty string, it will fallback to CLI arguments + pub fn load_file(path: &str) -> Graph { + let mut graph = if path.is_empty() { + Self::read_file(None) + } else { + Self::read_file(Some(path)) + }; + graph.modulate(); + graph + } + + /// Reads a TOML fie into a Graph without modulating it + pub fn read_file(in_path: Option<&str>) -> Graph { + let cli_path = Arguments::default().parse().graph_path; + let path = in_path.map_or(cli_path, PathBuf::from); + + let toml_source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => format!("Error: {e}"), + }; + + Self::from_serial(&toml_source, &Format::TOML) + } + + pub fn from_serial(serial: &str, format: &Format) -> Graph { + match *format { + Format::TOML => match toml::from_str(serial) { + Ok(g) => g, + Err(error) => Graph::error(Some(&error.to_string())), + }, + Format::JSON => match serde_json::from_str(serial) { + Ok(g) => g, + Err(error) => Graph::error(Some(&error.to_string())), + }, + } + } + + pub fn to_serial(graph: &Graph, format: &Format) -> String { + match *format { + Format::TOML => match toml::to_string(graph) { + Ok(s) => s, + Err(e) => e.to_string(), + }, + Format::JSON => match serde_json::to_string(graph) { + Ok(s) => s, + Err(e) => e.to_string(), + }, + } + } + + pub fn modulate(&mut self) { + self.map_lowercase_keys(); + self.modulate_nodes(); + self.modulate_edges(); + self.map_incoming(); + self.parse_config(); + } + + // Construct a HashMap with incoming connections (reversed edges) + fn map_incoming(&mut self) { + for node in self.nodes.clone().into_values() { + for edge in node.connections.clone().unwrap_or_default().values() { + let mut edges = self + .incoming + .get(&edge.to.clone()) + .unwrap_or(&vec![]) + .clone(); + edges.extend_from_slice(std::slice::from_ref(edge)); + self.incoming.insert(edge.to.clone(), edges.clone()); + } + } + } + + fn modulate_nodes(&mut self) { + let in_nodes = self.nodes.clone(); + + for (key, node) in in_nodes.clone() { + let connections = node.connections.clone().unwrap_or_default(); + let mut new_edges = connections.clone(); + + // Modulate connections + 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(&connection_key); + } + + // Flag detached edges + if !in_nodes.contains_key(&edge.to) { + new_edge.detached = false; + } + + 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.insert( + link.clone(), + Edge { + from: key.clone(), + to: link.clone(), + detached: !in_nodes.clone().contains_key(link), + }, + ); + } + + // Populate empty titles with IDs + let new_title = if node.title.is_empty() { + key.clone() + } else { + node.title.clone() + }; + + // Populate empty summaries with the leading part of the node text + let summary = if node.summary.is_empty() { + let first_line = if let Some(first) = + node.text.lines().find(|s| !s.is_empty()) + { + String::from(first) + } else { + node.text.clone() + }; + + let mut candidate = + if let Some(dot_split) = first_line.split_once('.') { + format!("{}.", dot_split.0) + } else { + first_line + }; + + if candidate.len() > 300 { + candidate.truncate(300); + candidate.push('…'); + } + candidate + } else { + node.summary.clone() + }; + + // Assemble new node + let new_node = Node { + id: key.clone(), + title: new_title, + summary: flatten(&summary, self), + connections: Some(new_edges), + ..node.clone() + }; + + self.nodes.insert(key.clone(), new_node); + } + } + + fn modulate_edges(&mut self) { + let graph = self.clone(); + let iterator = self.nodes.iter_mut(); + for (key, node) in iterator { + // Parse node text + let parse_output = content::rich_parse(&node.text, &graph); + node.text = parse_output.text.unwrap_or_default(); + + // Create connections for each anchor + let mut parsed_anchor_tokens = parse_output + .tokens + .iter() + .filter(|t| matches!(t, Token::Anchor(_))) + .cloned() + .collect::>(); + parsed_anchor_tokens.extend_from_slice( + &parse_output + .format_tokens + .iter() + .filter(|t| matches!(t, Token::Anchor(_))) + .cloned() + .collect::>(), + ); + let mut anchors: Vec = vec![]; + for token in parsed_anchor_tokens { + if let Token::Anchor(token_data) = token { + anchors.push(*token_data.clone()); + } + } + + for anchor in anchors { + if let Some(anchor_node) = anchor.node() { + if let Some(ref mut connections) = node.connections { + connections.insert( + anchor_node.id.clone(), + Edge { + from: key.clone(), + to: anchor_node.id, + detached: false, + }, + ); + } + } + } } } @@ -124,7 +338,7 @@ impl Graph { self.nodes.get(&self.root_node).cloned() } - pub fn parse(&mut self) { + pub fn parse_config(&mut self) { self.meta.config.footer_text = content::parse(&self.meta.config.footer_text, self); self.meta.config.about_text = @@ -132,13 +346,18 @@ impl Graph { } } +pub enum Format { + TOML, + JSON, +} + #[cfg(test)] mod tests { use super::*; #[test] fn empty_graph() { - let graph = Graph::new(Some("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj")); + let graph = Graph::error(Some("ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj")); assert!(graph.nodes.is_empty()); assert!(graph.incoming.is_empty()); assert_eq!( @@ -146,4 +365,57 @@ mod tests { "ISryQFd9peG6eYz9CFRQFWeD1GnPo0oj" ); } + + #[test] + fn good_json() { + let json = r#" + { + "nodes": { + "JSON": { + "text": "", + "title": "JSON", + "links": [], + "id": "JSON", + "hidden": false, + "connections": {} + } + }, + "root_node": "JSON" + } + "#; + + let graph = Graph::from_serial(json, &Format::JSON); + assert!(graph.meta.messages.is_empty()); + } + + #[test] + fn bad_json() { + let graph = Graph::from_serial(":::", &Format::JSON); + let message = graph.meta.messages.first().unwrap(); + assert!(message.contains("expected value at line 1 column 1")); + } +} + +#[cfg(test)] +mod serial_tests { + use super::*; + + #[test] + fn bad_graph_path() { + let original_working_directory = std::env::current_dir().unwrap(); + + assert!( + std::env::set_current_dir(std::path::Path::new( + "tests/mocks/no_graph" + )) + .is_ok() + ); + + let graph = Graph::load(); + let message = graph.meta.messages.first().unwrap(); + assert!(message.contains("TOML parse error")); + assert!(message.contains("No such file or directory")); + + assert!(std::env::set_current_dir(original_working_directory).is_ok()); + } } diff --git a/src/graph/meta.rs b/src/graph/meta.rs index 7a689d1..3108b94 100644 --- a/src/graph/meta.rs +++ b/src/graph/meta.rs @@ -1,14 +1,24 @@ use serde::{Serialize, Deserialize}; -#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct Meta { pub config: Config, - #[serde(default = "mkversion")] - pub version: (u8, u8, u8), + #[serde(default)] + pub version: Option, #[serde(default)] pub messages: Vec, } +impl Default for Meta { + fn default() -> Meta { + Meta { + config: Config::default(), + version: Version::from_env(), + messages: vec![], + } + } +} + #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct Config { #[serde(default)] @@ -99,25 +109,22 @@ fn mkfalse() -> bool { fn mk8() -> u16 { 8 } -fn mkversion() -> (u8, u8, u8) { - (0, 0, 0) -} - #[cfg(test)] mod tests { + use crate::graph::Graph; + use super::*; - use crate::syntax::serial::populate_graph; #[test] fn empty_footer_text() { - let mut graph = populate_graph(); + let mut graph = Graph::load(); graph.meta.config = Config { footer_text: String::default(), ..graph.meta.config }; - graph.parse(); + graph.parse_config(); println!("{:?}", graph.meta.config.footer_text); assert!(graph.meta.config.footer_text.is_empty()); @@ -126,14 +133,14 @@ mod tests { #[test] fn config_footer_text() { let payload = "0kqBrdS8NPrU4xVxh2xW0hUzAw926JCQ"; - let mut graph = populate_graph(); + let mut graph = Graph::load(); graph.meta.config = Config { footer_text: format!("`{payload}`"), ..graph.meta.config }; - graph.parse(); + graph.parse_config(); assert!( graph @@ -149,14 +156,14 @@ mod tests { #[test] fn config_about_text() { let payload = "ZqPFl84JlzSS0QUo61RwTUPONIE78Lmw"; - let mut graph = populate_graph(); + let mut graph = Graph::load(); graph.meta.config = Config { about_text: format!("`{payload}`"), ..graph.meta.config }; - graph.parse(); + graph.parse_config(); assert!( graph @@ -169,3 +176,59 @@ mod tests { ); } } + +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq, Debug)] +pub struct Version { + major: u8, + minor: u8, + patch: u8, +} + +impl Version { + pub fn from_env() -> Option { + Self::from(env!("CARGO_PKG_VERSION")) + } + + pub fn from(version: &str) -> Option { + let triple: Vec = + version.split('.').map(str::to_string).collect(); + + let has_two_dots = version.matches('.').count() == 2; + let has_three_elements = triple.len() == 3; + let has_whitespace = version.contains(' ') || version.contains('\n'); + let has_contiguous_dots = version.contains(".."); + let ends_with_dot = version.ends_with('.'); + let starts_with_dot = version.starts_with('.'); + + let major: u8 = if let Some(s) = triple.first() { + s.trim_start_matches('v').trim().parse().ok()? + } else { + return None; + }; + + let minor: u8 = if let Some(s) = triple.get(1) { + s.trim().parse().ok()? + } else { + return None; + }; + + let patch: u8 = if let Some(s) = triple.get(2) { + s.trim().parse().ok()? + } else { + return None; + }; + + let conditions = has_two_dots + && has_three_elements + && !has_whitespace + && !has_contiguous_dots + && !ends_with_dot + && !starts_with_dot; + + conditions.then_some(Version { + major, + minor, + patch, + }) + } +} diff --git a/src/main.rs b/src/main.rs index 619432d..08a096e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::{backtrace, io, panic}; -use en::{prelude::*, ONSET, syntax::serial::populate_graph, syntax}; +use en::{prelude::*, ONSET, graph::Graph, syntax}; #[tokio::main] async fn main() -> io::Result<()> { @@ -34,7 +34,7 @@ async fn main() -> io::Result<()> { } })); - let graph = populate_graph(); + let graph = Graph::load(); let router = en::router::new(&graph); let listener = diff --git a/src/router.rs b/src/router.rs index b0f70dd..b799470 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,6 +1,6 @@ use axum::{routing::get, Router}; -use crate::{syntax::serial::Format, graph::Graph}; +use crate::{graph::Format, graph::Graph}; mod handlers { pub mod graph; @@ -69,8 +69,7 @@ pub fn new(graph: &Graph) -> Router { #[cfg(test)] mod tests { use crate::{ - syntax::serial::populate_graph, - graph::{Config, Meta}, + graph::{Graph, Config, Meta}, }; use super::*; @@ -82,7 +81,7 @@ mod tests { use tower::ServiceExt as _; async fn request(uri: &str, config: Option<&Config>) -> Response { - let default_graph = populate_graph(); + let default_graph = Graph::load(); let graph = Graph { meta: Meta { config: config diff --git a/src/router/handlers/error.rs b/src/router/handlers/error.rs index adfab1a..26ee7d8 100644 --- a/src/router/handlers/error.rs +++ b/src/router/handlers/error.rs @@ -3,7 +3,7 @@ use axum::{ http::{Response, StatusCode, header}, }; -use crate::{syntax::serial::populate_graph, router::handlers}; +use crate::{graph::Graph, router::handlers}; pub(in crate::router::handlers) fn by_code( code: Option, @@ -26,7 +26,7 @@ fn make_body(code: Option, message: Option<&str>) -> String { let out_code = code.unwrap_or(500); let out_message = &message.unwrap_or("Unknown error"); - let config = populate_graph().meta.config; + let config = Graph::load().meta.config; context.insert( "title", diff --git a/src/router/handlers/fixed.rs b/src/router/handlers/fixed.rs index 90995bd..4f242ac 100644 --- a/src/router/handlers/fixed.rs +++ b/src/router/handlers/fixed.rs @@ -6,7 +6,7 @@ use axum::{ use crate::prelude::*; use crate::{ router::handlers, - syntax::serial::{Format, populate_graph, serialize_graph}, + graph::{Graph, Format}, }; /// # Panics @@ -35,8 +35,8 @@ pub async fn file(file_path: &str, content_type: &str) -> Response { #[expect(clippy::unused_async)] pub async fn serial(format: &Format) -> Response { - let graph = populate_graph(); - let body = serialize_graph(format, &graph); + let graph = Graph::load(); + let body = Graph::to_serial(&graph, format); match *format { Format::TOML => handlers::raw::make_response( diff --git a/src/router/handlers/graph.rs b/src/router/handlers/graph.rs index 78facc0..f21a648 100644 --- a/src/router/handlers/graph.rs +++ b/src/router/handlers/graph.rs @@ -2,10 +2,10 @@ 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}; +use crate::{graph::Graph, router::handlers, graph::Node}; pub async fn node(Path(id): Path) -> Response { - let graph = populate_graph(); + let graph = Graph::load(); let result = graph.find_node(&id); let found = result.node.is_some(); let nodes: Vec = graph.nodes.clone().into_values().collect(); diff --git a/src/router/handlers/navigation.rs b/src/router/handlers/navigation.rs index 913a3ab..35ccd7a 100644 --- a/src/router/handlers/navigation.rs +++ b/src/router/handlers/navigation.rs @@ -5,12 +5,15 @@ use axum::{ Form, }; -use crate::{syntax::serial::populate_graph, router::handlers, graph::Node}; +use crate::{ + graph::{Graph, Node}, + router::handlers, +}; #[expect(clippy::unused_async)] pub async fn page(template: &str) -> Response { let mut context = tera::Context::default(); - let graph = populate_graph(); + let graph = Graph::load(); let root_node = graph.get_root().unwrap_or_default(); let nodes: Vec = graph.nodes.into_values().collect(); diff --git a/src/router/handlers/template.rs b/src/router/handlers/template.rs index 8549bb4..78dd2a3 100644 --- a/src/router/handlers/template.rs +++ b/src/router/handlers/template.rs @@ -103,6 +103,7 @@ fn emergency_wrap(error: &tera::Error) -> String { #[cfg(test)] mod tests { + use crate::graph::Graph; use super::*; @@ -154,7 +155,7 @@ mod tests { let payload = "dBgIw8DnNHxJojiXzu445qUC4UpxwZCy"; let mut context = tera::Context::default(); let node = crate::graph::Node::new(Some(payload.to_string())); - let graph = crate::syntax::serial::populate_graph(); + let graph = Graph::load(); context.insert("node", &node); context .insert("text", &crate::syntax::content::parse(&node.text, &graph)); diff --git a/src/syntax.rs b/src/syntax.rs index c88a706..6632452 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1,3 +1,2 @@ pub mod command; pub mod content; -pub mod serial; diff --git a/src/syntax/content.rs b/src/syntax/content.rs index 081f6f7..f8ccefc 100644 --- a/src/syntax/content.rs +++ b/src/syntax/content.rs @@ -15,10 +15,16 @@ type Probe = fn(&Lexeme) -> bool; type Lexer = fn(&Lexeme) -> Token; type LexMap<'lm> = &'lm [(Probe, Lexer)]; +pub struct TokenOutput { + pub text: Option, + pub tokens: Vec, + pub format_tokens: Vec, +} + pub fn parse(text: &str, graph: &Graph) -> String { parser::read(text, graph) } -pub fn rich_parse(text: &str, graph: &Graph) -> (String, Vec) { +pub fn rich_parse(text: &str, graph: &Graph) -> TokenOutput { parser::rich_read(text, graph) } diff --git a/src/syntax/content/parser.rs b/src/syntax/content/parser.rs index 9d110f4..353c2f7 100644 --- a/src/syntax/content/parser.rs +++ b/src/syntax/content/parser.rs @@ -1,5 +1,5 @@ use crate::{prelude::*, graph::Graph}; -use super::{Parseable as _, LexMap}; +use super::{TokenOutput, Parseable as _, LexMap}; use token::{LineBreak, Literal}; use context::{Block, Inline}; pub use {lexeme::Lexeme, token::Token, state::State}; @@ -20,7 +20,7 @@ const LEXMAP: LexMap = &[ }), ]; -fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> Vec { +fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> TokenOutput { let mut tokens: Vec = Vec::default(); let mut state = State::default(); @@ -75,27 +75,37 @@ fn lex(text: &str, map: LexMap, graph: &Graph, blocking: bool) -> Vec { } context::close(&state, &mut tokens); - tokens + TokenOutput { + tokens, + format_tokens: state.format_tokens, + text: None, + } } pub(super) fn read(input: &str, graph: &Graph) -> String { - parse(&lex(input, LEXMAP, graph, true)) + parse(&lex(input, LEXMAP, graph, true).tokens) } -pub(super) fn rich_read(input: &str, graph: &Graph) -> (String, Vec) { - let tokens = lex(input, LEXMAP, graph, true); - let text = parse(&tokens); - (text, tokens) +pub(super) fn rich_read(input: &str, graph: &Graph) -> TokenOutput { + let lex_output = lex(input, LEXMAP, graph, true); + let text = parse(&lex_output.tokens); + TokenOutput { + text: Some(text), + tokens: lex_output.tokens, + format_tokens: lex_output.format_tokens, + } } + /// Apply end-to-end point and inline parsing for nested formatting, such as /// inside the display text of anchors and list items -pub fn format(input: &str, graph: &Graph) -> String { - parse(&lex(input, LEXMAP, graph, false)) +pub fn format(input: &str, graph: &Graph) -> (String, Vec) { + let tokens = lex(input, LEXMAP, graph, false).tokens; + (parse(&tokens), tokens) } // Strip special syntax for display in noninteractive or plain-text display pub fn flatten(input: &str, graph: &Graph) -> String { - let tokens = lex(input, LEXMAP, graph, true); + let tokens = lex(input, LEXMAP, graph, true).tokens; let flat = tokens.iter().map(Token::flatten).collect::(); log!("Flattened {tokens:?} to {flat}"); flat diff --git a/src/syntax/content/parser/context/anchor.rs b/src/syntax/content/parser/context/anchor.rs index be6f59b..c8c9cef 100644 --- a/src/syntax/content/parser/context/anchor.rs +++ b/src/syntax/content/parser/context/anchor.rs @@ -67,6 +67,9 @@ pub fn parse( } else if lexeme.match_char('|') && lexeme.is_next_delimiter() { log!("End: Pipe followed by delimiter"); if buffer.destination.is_empty() { + if candidate.text().contains(':') { + candidate.set_external(true); + } push(Some(&candidate.text().clone()), tokens, state, graph); } else { push(Some(&buffer.destination.clone()), tokens, state, graph); diff --git a/src/syntax/content/parser/context/list.rs b/src/syntax/content/parser/context/list.rs index e2cce76..37fe6ac 100644 --- a/src/syntax/content/parser/context/list.rs +++ b/src/syntax/content/parser/context/list.rs @@ -48,7 +48,10 @@ pub fn parse( } if item_candidate.depth.is_some() { // if the current item candidate has a known depth, push it - item_candidate.text = format(&item_candidate.text, graph); + let (text, format_tokens) = + format(&item_candidate.text, graph); + item_candidate.text = text; + state.format_tokens.extend_from_slice(&format_tokens); candidate.items.push(item_candidate.clone()); } // push list candidate, reset state and exit context @@ -60,7 +63,9 @@ pub fn parse( } else if lexeme.match_char('\n') { // found end of item, push it and reset state log!("Accepting item candidate {item_candidate}"); - item_candidate.text = format(&item_candidate.text, graph); + let (text, format_tokens) = format(&item_candidate.text, graph); + item_candidate.text = text; + state.format_tokens.extend_from_slice(&format_tokens); candidate.items.push(item_candidate.clone()); *item_candidate = Item::default(); buffer.depth = 0; diff --git a/src/syntax/content/parser/state.rs b/src/syntax/content/parser/state.rs index 7148036..851b0e7 100644 --- a/src/syntax/content/parser/state.rs +++ b/src/syntax/content/parser/state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use crate::syntax::content::parser::{ + Token, context::{Block, Context, Inline}, token::{Anchor, Item, List}, }; @@ -11,6 +12,7 @@ pub struct State { pub dom_ids: HashMap>, pub switches: Switches, pub buffers: Buffers, + pub format_tokens: Vec, } #[derive(Clone, Debug)] @@ -91,6 +93,7 @@ impl Default for State { depth: 0, }, }, + format_tokens: vec![], } } } diff --git a/src/syntax/serial.rs b/src/syntax/serial.rs deleted file mode 100644 index 77840ac..0000000 --- a/src/syntax/serial.rs +++ /dev/null @@ -1,275 +0,0 @@ -use std::collections::HashMap; - -use crate::{ - syntax::{ - command::Arguments, - content::{ - self, - parser::{flatten, Token, token::Anchor}, - }, - }, - graph::{Edge, Graph, Node}, -}; - -pub fn populate_graph() -> Graph { - let args = Arguments::default().parse(); - let toml_source = match std::fs::read_to_string(args.graph_path) { - Ok(s) => s, - Err(e) => format!("Error: {e}"), - }; - - let mut graph = deserialize_graph(&Format::TOML, &toml_source); - modulate_graph(&mut 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), - nodes, - ..in_graph.clone() - }; - - graph.parse(); - graph -} - -fn modulate_nodes(graph: &Graph) -> HashMap { - let in_nodes = graph.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(); - - // Modulate connections - 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(&connection_key); - } - - // Flag detached edges - if !in_nodes.contains_key(&edge.to) { - new_edge.detached = true; - } - - 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.insert( - link.clone(), - Edge { - from: key.clone(), - to: link.clone(), - detached: !in_nodes.clone().contains_key(link), - }, - ); - } - - // Populate empty titles with IDs - let new_title = if node.title.is_empty() { - key.clone() - } else { - node.title.clone() - }; - - // Populate empty summaries with the leading part of the node text - let summary = if node.summary.is_empty() { - let first_line = if let Some(first) = - node.text.lines().find(|s| !s.is_empty()) - { - String::from(first) - } else { - node.text.clone() - }; - - let mut candidate = - if let Some(dot_split) = first_line.split_once('.') { - format!("{}.", dot_split.0) - } else { - first_line - }; - - if candidate.len() > 300 { - candidate.truncate(300); - candidate.push('…'); - } - candidate - } else { - 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() - }; - - second_pass_nodes.insert(key.clone(), new_node); - } - - second_pass_nodes -} - -pub enum Format { - TOML, - JSON, -} - -pub fn serialize_graph(out_format: &Format, graph: &Graph) -> String { - match *out_format { - Format::TOML => match toml::to_string(graph) { - Ok(s) => s, - Err(e) => e.to_string(), - }, - Format::JSON => match serde_json::to_string(graph) { - Ok(s) => s, - Err(e) => e.to_string(), - }, - } -} - -pub fn deserialize_graph(in_format: &Format, serial: &str) -> Graph { - match *in_format { - Format::TOML => match toml::from_str(serial) { - Ok(g) => g, - Err(error) => Graph::new(Some(&error.to_string())), - }, - Format::JSON => match serde_json::from_str(serial) { - Ok(g) => g, - Err(error) => Graph::new(Some(&error.to_string())), - }, - } -} - -// Construct a HashMap with incoming connections (reversed edges) -fn make_incoming(nodes: &HashMap) -> HashMap> { - let mut incoming: HashMap> = HashMap::default(); - - for node in nodes.clone().into_values() { - let empty_vec: Vec = vec![]; - 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)); - incoming.insert(edge.to.clone(), edges.clone()); - } - } - - incoming -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn good_json() { - let json = r#" - { - "nodes": { - "JSON": { - "text": "", - "title": "JSON", - "links": [], - "id": "JSON", - "hidden": false, - "connections": {} - } - }, - "root_node": "JSON" - } - "#; - - let graph = deserialize_graph(&Format::JSON, json); - assert!(graph.meta.messages.is_empty()); - } - - #[test] - fn bad_json() { - let graph = deserialize_graph(&Format::JSON, ":::"); - let message = graph.meta.messages.first().unwrap(); - assert!(message.contains("expected value at line 1 column 1")); - } -} - -#[cfg(test)] -mod serial_tests { - use super::*; - - #[test] - fn bad_graph_path() { - let original_working_directory = std::env::current_dir().unwrap(); - - assert!( - std::env::set_current_dir(std::path::Path::new( - "tests/mocks/no_graph" - )) - .is_ok() - ); - - let graph = populate_graph(); - let message = graph.meta.messages.first().unwrap(); - assert!(message.contains("TOML parse error")); - assert!(message.contains("No such file or directory")); - - assert!(std::env::set_current_dir(original_working_directory).is_ok()); - } -} diff --git a/static/graph.toml b/static/graph.toml index 2b7c9a1..626bda5 100644 --- a/static/graph.toml +++ b/static/graph.toml @@ -517,7 +517,7 @@ en is only possible thanks to a number of projects and people: text = """ - [ ] Performance - [ ] Caching - - [ ] Move more logic from Serial to Graph submodules + - [x] Move more logic from Serial to Graph submodules - [ ] Further centralize state - [ ] Reduce O(n) calls in the formats module - [ ] Input syntax