use serde::Serialize;
use std::collections::BTreeMap;
use crate::service::triple_util::{
basename, obj_string, provenance_anchor_for, resolve_link_target, strip_brackets,
};
#[derive(Debug, Clone, Serialize, Default)]
pub struct Backlinks {
pub backlinks: Vec<BacklinkRef>,
pub outgoing: Vec<String>,
pub unlinked: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BacklinkRef {
pub path: String,
pub context: Option<String>,
pub provenance_anchor: Option<String>,
}
fn mentions_word(text: &str, word: &str) -> bool {
let w = word.trim().to_lowercase();
if w.is_empty() {
return false;
}
let hay = text.to_lowercase();
let hb = hay.as_bytes();
let mut from = 0;
while let Some(rel) = hay[from..].find(w.as_str()) {
let start = from + rel;
let end = start + w.len();
let before_ok = start == 0 || !(hb[start - 1] as char).is_alphanumeric();
let after_ok = end >= hb.len() || !(hb[end] as char).is_alphanumeric();
if before_ok && after_ok {
return true;
}
from = start + 1;
if from >= hb.len() {
break;
}
}
false
}
pub async fn backlinks(state: &crate::state::AppState, note: &str) -> Backlinks {
use aingle_graph::{Predicate, TriplePattern};
let (notes, links): (Vec<String>, Vec<(String, String)>) = {
let g = state.graph.read().await;
let collect = |pred: &str| -> Vec<(String, String)> {
g.find(TriplePattern::any().with_predicate(Predicate::named(pred)))
.unwrap_or_default()
.into_iter()
.filter_map(|t| {
obj_string(&t).map(|o| (strip_brackets(&t.subject.to_string()).to_string(), o))
})
.collect()
};
let mut notes: Vec<String> = collect(crate::service::ingest::PRED_SOURCE_HASH)
.into_iter()
.map(|(s, _)| s)
.collect();
notes.sort();
notes.dedup();
let links = collect("links_to");
(notes, links)
};
let note_set: std::collections::BTreeSet<&str> = notes.iter().map(|s| s.as_str()).collect();
let mut by_base: BTreeMap<String, String> = BTreeMap::new();
for n in ¬es {
by_base.entry(basename(n)).or_insert_with(|| n.clone());
}
let resolve =
|target: &str| -> Option<String> { resolve_link_target(target, ¬e_set, &by_base) };
let active_base = basename(note);
let active_base_lc = active_base.to_lowercase();
let mut text_of: BTreeMap<String, String> = BTreeMap::new();
{
let mem = state.memory.read().await;
let mut entries = mem.stm.all_entries();
entries.extend(mem.ltm.all_entries());
for e in entries {
if e.entry_type != crate::service::ingest::CHUNK_ENTRY_TYPE {
continue;
}
if let (Some(p), Some(t)) = (
e.data.get("source_path").and_then(|v| v.as_str()),
e.data.get("text").and_then(|v| v.as_str()),
) {
let buf = text_of.entry(p.to_string()).or_default();
buf.push('\n');
buf.push_str(t);
}
}
}
let mut backlink_paths = std::collections::BTreeSet::new();
let mut backlinks: Vec<BacklinkRef> = Vec::new();
for (src, target) in &links {
if src == note || !note_set.contains(src.as_str()) {
continue;
}
if resolve(target).as_deref() == Some(note) && backlink_paths.insert(src.clone()) {
let context = text_of.get(src).and_then(|txt| {
txt.lines()
.find(|l| l.contains("[[") && l.to_lowercase().contains(&active_base_lc))
.map(|l| {
let t = l.trim();
if t.chars().count() > 200 {
let cut: String = t.chars().take(200).collect();
format!("{cut}…")
} else {
t.to_string()
}
})
});
let anchor = provenance_anchor_for(state, src).await;
backlinks.push(BacklinkRef {
path: src.clone(),
context,
provenance_anchor: anchor,
});
}
}
backlinks.sort_by(|a, b| a.path.cmp(&b.path));
let mut outgoing: Vec<String> = links
.iter()
.filter(|(src, _)| src == note)
.filter_map(|(_, target)| resolve(target))
.filter(|p| p != note)
.collect();
outgoing.sort();
outgoing.dedup();
let mut unlinked: Vec<String> = text_of
.iter()
.filter(|(p, _)| {
p.as_str() != note
&& !backlink_paths.contains(p.as_str())
&& note_set.contains(p.as_str())
})
.filter(|(_, txt)| mentions_word(txt, &active_base))
.map(|(p, _)| p.clone())
.collect();
unlinked.sort();
unlinked.dedup();
Backlinks {
backlinks,
outgoing,
unlinked,
}
}
#[cfg(test)]
mod tests {
use crate::state::AppState;
use aingle_graph::{NodeId, Predicate, Triple, Value};
async fn graph_with(triples: &[(&str, &str, &str)]) -> AppState {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let g = state.graph.write().await;
for (s, p, o) in triples {
g.insert(Triple::new(
NodeId::named(*s),
Predicate::named(*p),
Value::literal(*o),
))
.unwrap();
}
}
state
}
#[tokio::test]
async fn backlinks_outgoing_unlinked() {
let state = graph_with(&[
("a.md", "aingle:source_hash", "h1"),
("b.md", "aingle:source_hash", "h2"),
("c.md", "aingle:source_hash", "h3"),
("target.md", "aingle:source_hash", "h4"),
("a.md", "links_to", "target"), ("target.md", "links_to", "b"), ])
.await;
{
let mut mem = state.memory.write().await;
let mut e = ineru::MemoryEntry::new(
crate::service::ingest::CHUNK_ENTRY_TYPE,
serde_json::json!({ "text": "See target for details.", "source_path": "c.md" }),
);
e.embedding = Some(ineru::Embedding::new(vec![0.0; 8]));
mem.remember(e).unwrap();
}
let r = super::backlinks(&state, "target.md").await;
assert!(
r.backlinks.iter().any(|b| b.path == "a.md"),
"a links to target"
);
assert!(
r.outgoing.contains(&"b.md".to_string()),
"target links to b"
);
assert!(
r.unlinked.contains(&"c.md".to_string()),
"c mentions target unlinked"
);
assert!(
!r.unlinked.contains(&"a.md".to_string()),
"a is a backlink, not unlinked"
);
}
#[tokio::test]
async fn unlinked_detects_hyphenated_basename() {
let state = graph_with(&[
("meeting-notes.md", "aingle:source_hash", "h1"),
("c.md", "aingle:source_hash", "h2"),
])
.await;
{
let mut mem = state.memory.write().await;
let mut e = ineru::MemoryEntry::new(
crate::service::ingest::CHUNK_ENTRY_TYPE,
serde_json::json!({ "text": "Discussed in meeting-notes yesterday.", "source_path": "c.md" }),
);
e.embedding = Some(ineru::Embedding::new(vec![0.0; 8]));
mem.remember(e).unwrap();
}
let r = super::backlinks(&state, "meeting-notes.md").await;
assert!(
r.unlinked.contains(&"c.md".to_string()),
"hyphenated name must be detected: {r:?}"
);
}
#[test]
fn mentions_word_is_bounded() {
assert!(super::mentions_word("a meeting-notes b", "meeting-notes"));
assert!(!super::mentions_word("my notebook here", "note"));
assert!(super::mentions_word("see Target.", "target"));
}
#[tokio::test]
async fn links_to_node_objects_are_captured() {
let state = crate::state::AppState::with_db_path(":memory:", None).unwrap();
{
let g = state.graph.write().await;
for (s, p) in [
("a.md", "aingle:source_hash"),
("hub.md", "aingle:source_hash"),
] {
g.insert(Triple::new(
NodeId::named(s),
Predicate::named(p),
Value::literal("h"),
))
.unwrap();
}
g.insert(Triple::new(
NodeId::named("a.md"),
Predicate::named("links_to"),
Value::Node(NodeId::named("hub")),
))
.unwrap();
}
let r = super::backlinks(&state, "hub.md").await;
assert!(
r.backlinks.iter().any(|b| b.path == "a.md"),
"node-valued links_to must appear as a backlink: {r:?}"
);
let r2 = super::backlinks(&state, "a.md").await;
assert!(
r2.outgoing.contains(&"hub.md".to_string()),
"node-valued links_to must appear as outgoing: {r2:?}"
);
}
#[tokio::test]
async fn context_truncation_is_char_safe() {
let state = graph_with(&[
("t.md", "aingle:source_hash", "h1"),
("src.md", "aingle:source_hash", "h2"),
("src.md", "links_to", "t"),
])
.await;
{
let mut mem = state.memory.write().await;
let long = format!("[[t]] {}", "áéíóú ".repeat(80));
let mut e = ineru::MemoryEntry::new(
crate::service::ingest::CHUNK_ENTRY_TYPE,
serde_json::json!({ "text": long, "source_path": "src.md" }),
);
e.embedding = Some(ineru::Embedding::new(vec![0.0; 8]));
mem.remember(e).unwrap();
}
let r = super::backlinks(&state, "t.md").await;
let b = r
.backlinks
.iter()
.find(|b| b.path == "src.md")
.expect("backlink");
let ctx = b.context.as_ref().expect("context");
assert!(ctx.chars().count() <= 201);
}
}