use std::collections::HashMap;
use papaya::HashMap as ConcurrentHashMap;
use crate::relationships::{NoteRelInput, normalize_name};
pub struct WikilinkIndex {
by_title: ConcurrentHashMap<String, String>,
by_alias: ConcurrentHashMap<String, String>,
by_stem: ConcurrentHashMap<String, String>,
by_dir_stem: ConcurrentHashMap<(String, String), String>,
}
impl Default for WikilinkIndex {
fn default() -> Self {
Self::new()
}
}
impl WikilinkIndex {
pub fn new() -> Self {
Self {
by_title: ConcurrentHashMap::new(),
by_alias: ConcurrentHashMap::new(),
by_stem: ConcurrentHashMap::new(),
by_dir_stem: ConcurrentHashMap::new(),
}
}
pub fn rebuild(&self, notes: &[NoteRelInput]) {
let mut sorted: Vec<&NoteRelInput> = notes.iter().collect();
sorted.sort_by(|a, b| a.url.cmp(&b.url));
let mut by_title: HashMap<String, String> = HashMap::new();
let mut by_alias: HashMap<String, String> = HashMap::new();
let mut by_stem: HashMap<String, String> = HashMap::new();
let mut by_dir_stem: HashMap<(String, String), String> = HashMap::new();
for note in &sorted {
by_title
.entry(normalize_name(¬e.title))
.or_insert_with(|| note.url.clone());
for alias in ¬e.aliases {
by_alias
.entry(normalize_name(alias))
.or_insert_with(|| note.url.clone());
}
let stem_key = normalize_name(¬e.stem);
by_stem
.entry(stem_key.clone())
.or_insert_with(|| note.url.clone());
by_dir_stem
.entry((page_folder(¬e.url, note.is_index), stem_key))
.or_insert_with(|| note.url.clone());
}
swap_in(&self.by_title, by_title);
swap_in(&self.by_alias, by_alias);
swap_in(&self.by_stem, by_stem);
swap_in(&self.by_dir_stem, by_dir_stem);
}
pub fn resolve_wikilink(
&self,
name: &str,
current_page_url: &str,
current_is_index: bool,
) -> Option<String> {
let (base, anchor) = match name.split_once('#') {
Some((base, anchor)) => (base.trim(), Some(anchor)),
None => (name.trim(), None),
};
if base.is_empty() {
return None;
}
let key = normalize_name(base);
let current_folder = page_folder(current_page_url, current_is_index);
if self
.by_dir_stem
.pin()
.get(&(current_folder, key.clone()))
.is_some()
{
return None;
}
let url = {
let title = self.by_title.pin();
let alias = self.by_alias.pin();
let stem = self.by_stem.pin();
title
.get(&key)
.or_else(|| alias.get(&key))
.or_else(|| stem.get(&key))
.cloned()
}?;
Some(match anchor {
Some(anchor) => format!("{url}#{anchor}"),
None => url,
})
}
pub fn clear(&self) {
self.by_title.pin().clear();
self.by_alias.pin().clear();
self.by_stem.pin().clear();
self.by_dir_stem.pin().clear();
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.by_title.pin().is_empty()
&& self.by_alias.pin().is_empty()
&& self.by_stem.pin().is_empty()
&& self.by_dir_stem.pin().is_empty()
}
}
fn swap_in<K>(target: &ConcurrentHashMap<K, String>, source: HashMap<K, String>)
where
K: Clone + Eq + std::hash::Hash + Send + Sync + 'static,
{
let guard = target.pin();
guard.clear();
for (key, value) in source {
guard.insert(key, value);
}
}
fn page_folder(url: &str, is_index: bool) -> String {
let segments: Vec<&str> = url
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
let folder = if is_index || segments.is_empty() {
segments.join("/")
} else {
segments[..segments.len() - 1].join("/")
};
normalize_name(&folder)
}
#[cfg(test)]
mod tests {
use super::*;
fn note(url: &str, title: &str, stem: &str, is_index: bool) -> NoteRelInput {
NoteRelInput {
url: url.to_string(),
title: title.to_string(),
stem: stem.to_string(),
aliases: Vec::new(),
is_index,
relationships: Vec::new(),
}
}
#[test]
fn same_folder_stem_returns_none() {
let idx = WikilinkIndex::new();
idx.rebuild(&[
note(
"/notes/patrick-walsh/",
"Patrick Walsh",
"patrick-walsh",
false,
),
note("/notes/family/", "Family", "family", false),
]);
assert_eq!(
idx.resolve_wikilink("patrick-walsh", "/notes/family/", false),
None
);
}
#[test]
fn global_fallback_returns_absolute_url() {
let idx = WikilinkIndex::new();
idx.rebuild(&[
note(
"/walsh/patrick-walsh/",
"Patrick Walsh",
"patrick-walsh",
false,
),
note("/notes/family/", "Family", "family", false),
]);
assert_eq!(
idx.resolve_wikilink("Patrick Walsh", "/notes/family/", false),
Some("/walsh/patrick-walsh/".to_string())
);
}
#[test]
fn ambiguous_name_resolves_to_smallest_url() {
let idx = WikilinkIndex::new();
idx.rebuild(&[
note("/z/sam/", "Sam", "sam", false),
note("/a/sam/", "Sam", "sam", false),
]);
assert_eq!(
idx.resolve_wikilink("Sam", "/other/page/", false),
Some("/a/sam/".to_string())
);
}
#[test]
fn resolution_is_case_insensitive_title_alias_stem() {
let idx = WikilinkIndex::new();
let mut mary = note("/people/mary/", "Mary Smith", "mary", false);
mary.aliases = vec!["Mary Doe".to_string()];
idx.rebuild(&[mary]);
assert_eq!(
idx.resolve_wikilink("mary smith", "/x/", false),
Some("/people/mary/".to_string())
);
assert_eq!(
idx.resolve_wikilink("MARY DOE", "/x/", false),
Some("/people/mary/".to_string())
);
assert_eq!(
idx.resolve_wikilink("Mary", "/x/", false),
Some("/people/mary/".to_string())
);
}
#[test]
fn missing_name_returns_none() {
let idx = WikilinkIndex::new();
idx.rebuild(&[note("/a/", "A", "a", false)]);
assert_eq!(idx.resolve_wikilink("Nonexistent", "/x/", false), None);
}
#[test]
fn anchor_is_preserved() {
let idx = WikilinkIndex::new();
idx.rebuild(&[note(
"/walsh/patrick-walsh/",
"Patrick Walsh",
"patrick-walsh",
false,
)]);
assert_eq!(
idx.resolve_wikilink("Patrick Walsh#early-life", "/notes/x/", false),
Some("/walsh/patrick-walsh/#early-life".to_string())
);
}
#[test]
fn index_page_folder_is_the_directory_itself() {
let idx = WikilinkIndex::new();
idx.rebuild(&[
note("/people/", "People", "index", true),
note("/people/john/", "John", "john", false),
]);
assert_eq!(idx.resolve_wikilink("john", "/people/", true), None);
}
#[test]
fn rebuild_replaces_prior_contents() {
let idx = WikilinkIndex::new();
idx.rebuild(&[note("/a/", "Alpha", "alpha", false)]);
assert!(!idx.is_empty());
idx.rebuild(&[]);
assert!(idx.is_empty());
}
}