// Digest link parsing: extracts ## Links sections from digest nodes // and applies them to the memory graph. use crate::store::{self, Store, new_relation}; use regex::Regex; /// A parsed link from a digest's Links section. pub struct DigestLink { pub source: String, pub target: String, pub reason: String, pub file: String, } /// Normalize a raw link target to a poc-memory key. fn normalize_link_key(raw: &str) -> String { let key = raw.trim().trim_matches('`').trim(); if key.is_empty() { return String::new(); } // Self-references let lower = key.to_lowercase(); if lower.starts_with("this ") { return String::new(); } let mut key = key.to_string(); // Strip .md suffix if present if let Some(stripped) = key.strip_suffix(".md") { key = stripped.to_string(); } else if key.contains('#') { let (file, section) = key.split_once('#').unwrap(); if let Some(bare) = file.strip_suffix(".md") { key = format!("{}-{}", bare, section); } } // weekly/2026-W06 → weekly-2026-W06, etc. if let Some(pos) = key.find('/') { let prefix = &key[..pos]; if prefix == "daily" || prefix == "weekly" || prefix == "monthly" { let rest = &key[pos + 1..]; key = format!("{}-{}", prefix, rest); } } // Bare date → daily digest let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); if date_re.is_match(&key) { key = format!("daily-{}", key); } key } /// Parse the Links section from a digest node's content. fn parse_digest_node_links(key: &str, content: &str) -> Vec { let link_re = Regex::new(r"^-\s+(.+?)\s*[→↔←]\s*(.+?)(?:\s*\((.+?)\))?\s*$").unwrap(); let header_re = Regex::new(r"^##\s+Links").unwrap(); let mut links = Vec::new(); let mut in_links = false; for line in content.lines() { if header_re.is_match(line) { in_links = true; continue; } if in_links && line.starts_with("## ") { in_links = false; continue; } if !in_links { continue; } if line.starts_with("###") || line.starts_with("**") { continue; } if let Some(cap) = link_re.captures(line) { let raw_source = cap[1].trim(); let raw_target = cap[2].trim(); let reason = cap.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(); let mut source = normalize_link_key(raw_source); let mut target = normalize_link_key(raw_target); // Replace self-references with digest key if source.is_empty() { source = key.to_string(); } if target.is_empty() { target = key.to_string(); } // Handle "this daily/weekly/monthly" in raw text let raw_s_lower = raw_source.to_lowercase(); let raw_t_lower = raw_target.to_lowercase(); if raw_s_lower.contains("this daily") || raw_s_lower.contains("this weekly") || raw_s_lower.contains("this monthly") { source = key.to_string(); } if raw_t_lower.contains("this daily") || raw_t_lower.contains("this weekly") || raw_t_lower.contains("this monthly") { target = key.to_string(); } // Skip NEW: and self-links if source.starts_with("NEW:") || target.starts_with("NEW:") { continue; } if source == target { continue; } links.push(DigestLink { source, target, reason, file: key.to_string() }); } } links } /// Parse links from all digest nodes in the store. pub fn parse_all_digest_links(store: &Store) -> Vec { let mut all_links = Vec::new(); let mut digest_keys: Vec<&String> = store.nodes.iter() .filter(|(_, n)| matches!(n.node_type, store::NodeType::EpisodicDaily | store::NodeType::EpisodicWeekly | store::NodeType::EpisodicMonthly)) .map(|(k, _)| k) .collect(); digest_keys.sort(); for key in digest_keys { if let Some(node) = store.nodes.get(key) { all_links.extend(parse_digest_node_links(key, &node.content)); } } // Deduplicate by (source, target) pair let mut seen = std::collections::HashSet::new(); all_links.retain(|link| seen.insert((link.source.clone(), link.target.clone()))); all_links } /// Apply parsed digest links to the store. pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, usize, usize) { let mut applied = 0usize; let mut skipped = 0usize; let mut fallbacks = 0usize; for link in links { // Try resolving both keys let source = match store.resolve_key(&link.source) { Ok(s) => s, Err(_) => { // Try stripping section anchor as fallback if let Some(base) = link.source.split('#').next() { match store.resolve_key(base) { Ok(s) => { fallbacks += 1; s } Err(_) => { skipped += 1; continue; } } } else { skipped += 1; continue; } } }; let target = match store.resolve_key(&link.target) { Ok(t) => t, Err(_) => { if let Some(base) = link.target.split('#').next() { match store.resolve_key(base) { Ok(t) => { fallbacks += 1; t } Err(_) => { skipped += 1; continue; } } } else { skipped += 1; continue; } } }; if source == target { skipped += 1; continue; } // Check if link already exists let exists = store.relations.iter().any(|r| r.source_key == source && r.target_key == target && !r.deleted ); if exists { skipped += 1; continue; } let source_uuid = match store.nodes.get(&source) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; let target_uuid = match store.nodes.get(&target) { Some(n) => n.uuid, None => { skipped += 1; continue; } }; let rel = new_relation( source_uuid, target_uuid, store::RelationType::Link, 0.5, &source, &target, ); if store.add_relation(rel).is_ok() { println!(" + {} → {}", source, target); applied += 1; } } (applied, skipped, fallbacks) }