use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::{Link, Note, common, search};
pub struct VaultHealthReport {
pub note_count: usize,
pub duplicate_ids: Vec<DuplicateId>,
pub duplicate_aliases: Vec<DuplicateAlias>,
pub broken_links: Vec<BrokenLink>,
}
impl VaultHealthReport {
pub fn has_issues(&self) -> bool {
!self.duplicate_ids.is_empty() || !self.duplicate_aliases.is_empty() || !self.broken_links.is_empty()
}
}
pub struct DuplicateId {
pub id: String,
pub notes: Vec<NoteRef>,
}
pub struct DuplicateAlias {
pub alias: String,
pub notes: Vec<NoteRef>,
}
pub struct NoteRef {
pub path: PathBuf,
pub backlink_count: usize,
}
pub struct BrokenLink {
pub source_path: PathBuf,
pub line: usize,
pub text: String,
}
pub fn check_notes(vault_path: impl AsRef<Path>, notes: &[Note]) -> VaultHealthReport {
let vault_path = vault_path.as_ref();
let note_paths: HashSet<PathBuf> = notes.iter().map(|note| note.path.clone()).collect();
let mut id_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
for note in notes.iter() {
id_map.entry(note.id.clone()).or_default().push(note.path.clone());
}
let mut duplicate_ids: Vec<DuplicateId> = id_map
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(id, mut paths)| {
paths.sort();
let note_refs = paths
.into_iter()
.map(|path| {
let note = notes.iter().find(|n| n.path == path).unwrap();
NoteRef {
path: path.clone(),
backlink_count: backlinks_from(notes, note, vault_path).len(),
}
})
.collect();
DuplicateId { id, notes: note_refs }
})
.collect();
duplicate_ids.sort_by(|a, b| a.id.cmp(&b.id));
let mut alias_map: HashMap<String, HashSet<PathBuf>> = HashMap::new();
for note in notes.iter() {
for alias in ¬e.aliases {
alias_map
.entry(alias.to_lowercase())
.or_default()
.insert(note.path.clone());
}
}
let mut duplicate_aliases: Vec<DuplicateAlias> = alias_map
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(alias, paths)| {
let mut sorted_paths: Vec<PathBuf> = paths.into_iter().collect();
sorted_paths.sort();
let note_refs = sorted_paths
.into_iter()
.map(|path| {
let note = notes.iter().find(|n| n.path == path).unwrap();
NoteRef {
path: path.clone(),
backlink_count: backlinks_from(notes, note, vault_path).len(),
}
})
.collect();
DuplicateAlias {
alias,
notes: note_refs,
}
})
.collect();
duplicate_aliases.sort_by(|a, b| a.alias.cmp(&b.alias));
let mut valid_wiki_targets: HashSet<String> = HashSet::new();
for note in notes.iter() {
valid_wiki_targets.insert(note.id.clone());
if let Some(stem) = note.path.file_stem().and_then(|s| s.to_str()) {
valid_wiki_targets.insert(stem.to_string());
}
for alias in ¬e.aliases {
valid_wiki_targets.insert(alias.clone());
valid_wiki_targets.insert(alias.to_lowercase());
}
}
let mut broken_links: Vec<BrokenLink> = Vec::new();
for note in notes.iter() {
for ll in ¬e.links {
match &ll.link {
Link::Wiki { target, .. } => {
if !target.is_empty() && !valid_wiki_targets.contains(target.as_str()) {
broken_links.push(BrokenLink {
source_path: note.path.clone(),
line: ll.location.line,
text: format!("[[{}]]", target),
});
}
}
Link::Markdown { url, .. } => {
if url.contains("://") || url.starts_with('/') {
continue;
}
let url_path_raw = match url.find('#') {
Some(i) => &url[..i],
None => url.as_str(),
};
let url_path_decoded = common::percent_decode(url_path_raw);
let url_path = url_path_decoded.as_str();
if !url_path.ends_with(".md") {
continue;
}
let source_dirs = [vault_path, note.path.parent().unwrap_or(vault_path)];
if !source_dirs.iter().any(|dir| {
let candidate = common::normalize_path(url_path, Some(dir));
note_paths.contains(&candidate)
}) {
broken_links.push(BrokenLink {
source_path: note.path.clone(),
line: ll.location.line,
text: format!("[...]({})", url),
});
}
}
_ => {}
}
}
}
broken_links.sort_by(|a, b| a.source_path.cmp(&b.source_path).then(a.line.cmp(&b.line)));
VaultHealthReport {
note_count: notes.len(),
duplicate_ids,
duplicate_aliases,
broken_links,
}
}
pub fn backlinks_from<'a>(
notes: &'a [Note],
target: &Note,
vault_path: &Path,
) -> Vec<(&'a Note, Vec<crate::LocatedLink>)> {
notes
.iter()
.filter_map(|source| {
let matching = search::find_matching_links(source, target, vault_path);
if matching.is_empty() {
None
} else {
Some((source, matching))
}
})
.collect()
}