feat: parse generic relationships from frontmatter wikilinks

Adds a `relationships: HashMap<String, Vec<String>>` field to VaultEntry
that captures ALL frontmatter fields containing wikilinks (e.g. Has,
Topics, Events, Notes, Buckets, Highlights). Handles both single string
and array values. Known non-relationship keys (Is A, aliases, Status,
etc.) are skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-17 19:07:02 +01:00
parent f0a8f7d014
commit 99069be0d3

View File

@@ -31,6 +31,9 @@ pub struct VaultEntry {
#[serde(rename = "fileSize")]
pub file_size: u64,
pub snippet: String,
/// Generic relationship fields: any frontmatter key whose value contains wikilinks.
/// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events").
pub relationships: HashMap<String, Vec<String>>,
}
/// Intermediate struct to capture YAML frontmatter fields.
@@ -182,6 +185,54 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
serde_json::from_value(value).unwrap_or_default()
}
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a", "aliases", "status", "cadence", "created at", "created time",
];
/// Check if a string contains a wikilink pattern `[[...]]`.
fn contains_wikilink(s: &str) -> bool {
s.contains("[[") && s.contains("]]")
}
/// Extract all wikilink-containing fields from raw YAML frontmatter.
/// Returns a HashMap where each key is the original frontmatter field name
/// and the value is a Vec of wikilink strings found in that field.
/// Handles both single string values and arrays of strings.
fn extract_relationships(data: &HashMap<String, serde_json::Value>) -> HashMap<String, Vec<String>> {
let mut relationships = HashMap::new();
for (key, value) in data {
// Skip known non-relationship keys
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
continue;
}
match value {
serde_json::Value::String(s) => {
if contains_wikilink(s) {
relationships.insert(key.clone(), vec![s.clone()]);
}
}
serde_json::Value::Array(arr) => {
let wikilinks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| contains_wikilink(s))
.map(|s| s.to_string())
.collect();
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
_ => {}
}
}
relationships
}
/// Parse a single markdown file into a VaultEntry.
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
@@ -194,7 +245,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let frontmatter: Frontmatter = if let Some(data) = parsed.data {
let (frontmatter, relationships): (Frontmatter, HashMap<String, Vec<String>>) = if let Some(data) = parsed.data {
match data {
gray_matter::Pod::Hash(map) => {
// Convert Pod HashMap to serde_json HashMap
@@ -202,12 +253,14 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
.into_iter()
.map(|(k, v)| (k, pod_to_json(v)))
.collect();
parse_frontmatter(&json_map)
let fm = parse_frontmatter(&json_map);
let rels = extract_relationships(&json_map);
(fm, rels)
}
_ => Frontmatter::default(),
_ => (Frontmatter::default(), HashMap::new()),
}
} else {
Frontmatter::default()
(Frontmatter::default(), HashMap::new())
};
let title = extract_title(&parsed.content, &filename);
@@ -273,6 +326,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
created_at,
file_size,
snippet,
relationships,
})
}
@@ -831,5 +885,82 @@ This is a project note.
assert_eq!(entries2[0].title, "Note");
}
#[test]
fn test_parse_relationships_array() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Responsibility
Has:
- "[[essay/foo|Foo Essay]]"
- "[[essay/bar|Bar Essay]]"
Topics:
- "[[topic/rust]]"
- "[[topic/wasm]]"
Status: Active
---
# Publish Essays
"#;
create_test_file(dir.path(), "publish-essays.md", content);
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
assert_eq!(entry.relationships.len(), 2);
assert_eq!(
entry.relationships.get("Has").unwrap(),
&vec!["[[essay/foo|Foo Essay]]".to_string(), "[[essay/bar|Bar Essay]]".to_string()]
);
assert_eq!(
entry.relationships.get("Topics").unwrap(),
&vec!["[[topic/rust]]".to_string(), "[[topic/wasm]]".to_string()]
);
}
#[test]
fn test_parse_relationships_single_string() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Owner: "[[person/luca-rossi|Luca Rossi]]"
Belongs to:
- "[[responsibility/grow-newsletter]]"
---
# Some Project
"#;
create_test_file(dir.path(), "some-project.md", content);
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
// Owner contains a wikilink, so it should appear in relationships
assert_eq!(
entry.relationships.get("Owner").unwrap(),
&vec!["[[person/luca-rossi|Luca Rossi]]".to_string()]
);
// Belongs to is also a wikilink array, should appear in relationships
assert_eq!(
entry.relationships.get("Belongs to").unwrap(),
&vec!["[[responsibility/grow-newsletter]]".to_string()]
);
// Still parsed in the dedicated field too
assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]);
}
#[test]
fn test_parse_relationships_ignores_non_wikilinks() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Note
Status: Active
Tags:
- productivity
- writing
Custom Field: just a plain string
---
# A Note
"#;
create_test_file(dir.path(), "plain-note.md", content);
let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap();
// Tags and Custom Field don't contain wikilinks, so relationships should be empty
assert!(entry.relationships.is_empty());
}
// Frontmatter update/delete tests are in frontmatter.rs
}