clin-rs 0.8.20

Encrypted terminal note-taking app inspired by Obsidian
use std::collections::HashMap;
use std::sync::Mutex;

use fdg_sim::petgraph::graph::NodeIndex;
use fdg_sim::{ForceGraph, ForceGraphHelper, Simulation, SimulationParameters};

use crate::config::ClinConfig;
use crate::storage::Storage;

pub struct GraphNodeData {
    pub note_id: String,
    pub title: String,
    pub tags: Vec<String>,
    pub link_count: usize,
    pub folder: String,
}

pub struct GraphState {
    pub simulation: Simulation<GraphNodeData, ()>,
    pub viewport: super::viewport::Viewport,
    pub selected_node: Option<NodeIndex>,
    pub dragging_node: Option<NodeIndex>,
    pub drag_target: Option<(f32, f32)>,
    pub is_settled: bool,
    pub graph_bounds: (f64, f64, f64, f64),
    pub render_cache: Mutex<super::render::RenderCache>,
}

pub fn build_graph(
    storage: &Storage,
    config: &ClinConfig,
) -> anyhow::Result<ForceGraph<GraphNodeData, ()>> {
    let note_ids = storage.list_note_ids(!config.list.show_hidden_files)?;
    let mut graph: ForceGraph<GraphNodeData, ()> = ForceGraph::default();
    let mut title_to_index: HashMap<String, NodeIndex> = HashMap::new();

    let mut summaries = Vec::new();
    let mut links_map: HashMap<String, Vec<String>> = HashMap::new();
    let mut link_counts: HashMap<String, usize> = HashMap::new();

    for id in &note_ids {
        if let Ok(summary) = storage.load_note_summary(id) {
            link_counts.insert(id.clone(), summary.links.len());
            links_map.insert(id.clone(), summary.links.clone());
            summaries.push(summary);
        }
    }

    let min_links = config.graf.filter.min_links;

    for summary in &summaries {
        let lc = link_counts.get(&summary.id).copied().unwrap_or(0);
        if lc < min_links {
            continue;
        }

        if !config.graf.filter.exclude_tags.is_empty()
            && summary
                .tags
                .iter()
                .any(|t| config.graf.filter.exclude_tags.contains(t))
        {
            continue;
        }

        let data = GraphNodeData {
            note_id: summary.id.clone(),
            title: summary.title.clone(),
            tags: summary.tags.clone(),
            link_count: lc,
            folder: summary.folder.clone(),
        };

        let idx = graph.add_force_node(&summary.title, data);
        title_to_index.insert(summary.title.to_lowercase(), idx);
    }

    for summary in &summaries {
        if let Some(links) = links_map.get(&summary.id) {
            let source_title = summary.title.to_lowercase();

            let source_idx = match title_to_index.get(&source_title) {
                Some(&idx) => idx,
                None => continue,
            };

            let mut seen_targets = std::collections::HashSet::new();
            for link in links {
                let target_lower = link.to_lowercase();
                if let Some(&target_idx) = title_to_index.get(&target_lower)
                    && target_idx != source_idx
                    && seen_targets.insert(target_idx)
                    && graph.edges_connecting(source_idx, target_idx).count() == 0
                {
                    graph.add_edge(source_idx, target_idx, ());
                }
            }
        }
    }

    Ok(graph)
}

pub fn create_simulation(
    graph: ForceGraph<GraphNodeData, ()>,
    config: &ClinConfig,
) -> Simulation<GraphNodeData, ()> {
    let force = fdg_sim::force::handy(config.graf.physics.ideal_distance as f32, 0.95, true, true);
    let params = SimulationParameters::new(800.0, fdg_sim::Dimensions::Two, force);
    Simulation::from_graph(graph, params)
}

impl GraphState {
    pub fn new(storage: &Storage, config: &ClinConfig) -> anyhow::Result<Self> {
        let graph = build_graph(storage, config)?;
        let simulation = create_simulation(graph, config);
        let mut state = Self {
            viewport: super::viewport::Viewport::default(),
            simulation,
            selected_node: None,
            dragging_node: None,
            drag_target: None,
            is_settled: false,
            graph_bounds: (0.0, 0.0, 0.0, 0.0),
            render_cache: Mutex::new(super::render::RenderCache::new()),
        };
        state.viewport = state
            .viewport
            .auto_fit_from_graph(state.simulation.get_graph(), 1.4);
        state.graph_bounds = super::render::compute_graph_bounds(state.simulation.get_graph());
        Ok(state)
    }
}

pub fn search_nodes(
    sim: &fdg_sim::Simulation<GraphNodeData, ()>,
    query: &str,
    max_results: usize,
) -> Vec<(fdg_sim::petgraph::graph::NodeIndex, String)> {
    if query.is_empty() {
        return Vec::new();
    }
    let q = query.to_lowercase();
    let graph = sim.get_graph();
    let mut results: Vec<(fdg_sim::petgraph::graph::NodeIndex, String)> = graph
        .node_indices()
        .filter_map(|idx| {
            let node = &graph[idx];
            let title_match = node.data.title.to_lowercase().contains(&q);
            let path_match = node.data.note_id.to_lowercase().contains(&q);
            let tag_match = node.data.tags.iter().any(|t| t.to_lowercase().contains(&q));
            if title_match || path_match || tag_match {
                Some((idx, node.data.title.clone()))
            } else {
                None
            }
        })
        .collect();

    results.sort_by(|a, b| {
        let a_starts = a.1.to_lowercase().starts_with(&q);
        let b_starts = b.1.to_lowercase().starts_with(&q);
        match (a_starts, b_starts) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.1.cmp(&b.1),
        }
    });

    results.truncate(max_results);
    results
}