Skip to content

Commit 984ba3c

Browse files
committed
feat(writer): add edit_frontmatter with granular YAML mutations
1 parent 57a9c52 commit 984ba3c

3 files changed

Lines changed: 338 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ categories = ["command-line-utilities", "database", "text-processing"]
1414
clap = { version = "4", features = ["derive"] }
1515
serde = { version = "1", features = ["derive"] }
1616
serde_json = "1"
17+
serde_yaml = "0.9"
1718
toml = "0.8"
1819
dirs = "5"
1920
anyhow = "1"

src/writer.rs

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ pub struct RewriteInput {
7272
pub modified_by: String,
7373
}
7474

75+
#[derive(Debug, Clone)]
76+
pub enum FrontmatterOp {
77+
Set(String, String),
78+
Remove(String),
79+
AddTag(String),
80+
RemoveTag(String),
81+
AddAlias(String),
82+
RemoveAlias(String),
83+
}
84+
85+
#[derive(Debug, Clone)]
86+
pub struct EditFrontmatterInput {
87+
pub file: String,
88+
pub operations: Vec<FrontmatterOp>,
89+
pub modified_by: String,
90+
}
91+
7592
#[derive(Debug, Clone, serde::Serialize)]
7693
pub struct WriteResult {
7794
pub path: String,
@@ -854,6 +871,160 @@ pub fn rewrite_note(store: &Store, vault_path: &Path, input: &RewriteInput) -> R
854871
})
855872
}
856873

874+
/// Edit frontmatter fields with granular operations (add/remove tags, set/remove properties).
875+
///
876+
/// Uses `crate::markdown::split_frontmatter()` to extract raw YAML, then applies
877+
/// operations sequentially using `serde_yaml`. Does NOT re-index chunks.
878+
pub fn edit_frontmatter(
879+
store: &Store,
880+
vault_path: &Path,
881+
input: &EditFrontmatterInput,
882+
) -> Result<EditResult> {
883+
// Step 1: Resolve file via store
884+
let file_record = store
885+
.resolve_file(&input.file)?
886+
.ok_or_else(|| anyhow::anyhow!("file not found: {}", input.file))?;
887+
888+
let full_path = vault_path.join(&file_record.path);
889+
890+
// Step 2: Read content from disk
891+
let content = std::fs::read_to_string(&full_path)?;
892+
893+
// Step 3: Split frontmatter using crate::markdown::split_frontmatter (returns raw YAML without delimiters)
894+
let (maybe_fm, body) = crate::markdown::split_frontmatter(&content);
895+
896+
// Step 4: Parse YAML into a Mapping (create empty mapping if no frontmatter)
897+
let mut mapping: serde_yaml::Mapping = if let Some(ref fm) = maybe_fm {
898+
let val: serde_yaml::Value = serde_yaml::from_str(fm)
899+
.unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
900+
match val {
901+
serde_yaml::Value::Mapping(m) => m,
902+
_ => serde_yaml::Mapping::new(),
903+
}
904+
} else {
905+
serde_yaml::Mapping::new()
906+
};
907+
908+
// Step 5: Apply operations sequentially
909+
for op in &input.operations {
910+
match op {
911+
FrontmatterOp::Set(key, value) => {
912+
mapping.insert(
913+
serde_yaml::Value::String(key.clone()),
914+
serde_yaml::Value::String(value.clone()),
915+
);
916+
}
917+
FrontmatterOp::Remove(key) => {
918+
mapping.remove(&serde_yaml::Value::String(key.clone()));
919+
}
920+
FrontmatterOp::AddTag(tag) => {
921+
apply_add_to_sequence(&mut mapping, "tags", tag);
922+
}
923+
FrontmatterOp::RemoveTag(tag) => {
924+
apply_remove_from_sequence(&mut mapping, "tags", tag);
925+
}
926+
FrontmatterOp::AddAlias(alias) => {
927+
apply_add_to_sequence(&mut mapping, "aliases", alias);
928+
}
929+
FrontmatterOp::RemoveAlias(alias) => {
930+
apply_remove_from_sequence(&mut mapping, "aliases", alias);
931+
}
932+
}
933+
}
934+
935+
// Step 6: Serialize back to YAML
936+
let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))?;
937+
938+
// Step 7: Reassemble: ---\n{yaml}---\n\n{body}
939+
// serde_yaml::to_string adds a trailing newline, so we don't need an extra one before ---
940+
let new_content = format!("---\n{}---\n\n{}", yaml_str, body);
941+
942+
// Step 8: Write atomically
943+
atomic_write(&full_path, &new_content, true)?;
944+
945+
// Update store with new content hash and mtime
946+
let content_hash = compute_content_hash(&new_content);
947+
let mtime = file_mtime(&full_path)?;
948+
let docid = file_record
949+
.docid
950+
.clone()
951+
.unwrap_or_else(|| generate_docid(&file_record.path));
952+
953+
// Extract updated tags from the written content for store update
954+
let (updated_fm, _) = crate::markdown::split_frontmatter(&new_content);
955+
let updated_tags: Vec<String> = if let Some(ref fm) = updated_fm {
956+
extract_yaml_sequence(fm, "tags")
957+
} else {
958+
vec![]
959+
};
960+
961+
store.insert_file(
962+
&file_record.path,
963+
&content_hash,
964+
mtime,
965+
&updated_tags,
966+
&docid,
967+
file_record.created_by.as_deref(),
968+
)?;
969+
970+
Ok(EditResult {
971+
path: file_record.path,
972+
heading: String::new(),
973+
mode: "EditFrontmatter".to_string(),
974+
})
975+
}
976+
977+
/// Helper: add a value to a YAML sequence field (create if missing, skip duplicates).
978+
fn apply_add_to_sequence(mapping: &mut serde_yaml::Mapping, key: &str, value: &str) {
979+
let key_val = serde_yaml::Value::String(key.to_string());
980+
let new_item = serde_yaml::Value::String(value.to_string());
981+
982+
let seq = mapping
983+
.entry(key_val)
984+
.or_insert_with(|| serde_yaml::Value::Sequence(vec![]));
985+
986+
if let serde_yaml::Value::Sequence(items) = seq {
987+
if !items.contains(&new_item) {
988+
items.push(new_item);
989+
}
990+
}
991+
}
992+
993+
/// Helper: remove a value from a YAML sequence field.
994+
fn apply_remove_from_sequence(mapping: &mut serde_yaml::Mapping, key: &str, value: &str) {
995+
let key_val = serde_yaml::Value::String(key.to_string());
996+
let remove_item = serde_yaml::Value::String(value.to_string());
997+
998+
if let Some(serde_yaml::Value::Sequence(items)) = mapping.get_mut(&key_val) {
999+
items.retain(|item| item != &remove_item);
1000+
}
1001+
}
1002+
1003+
/// Helper: extract string values from a YAML sequence field.
1004+
fn extract_yaml_sequence(yaml_str: &str, key: &str) -> Vec<String> {
1005+
let val: serde_yaml::Value = match serde_yaml::from_str(yaml_str) {
1006+
Ok(v) => v,
1007+
Err(_) => return vec![],
1008+
};
1009+
if let serde_yaml::Value::Mapping(ref m) = val {
1010+
if let Some(serde_yaml::Value::Sequence(items)) =
1011+
m.get(&serde_yaml::Value::String(key.to_string()))
1012+
{
1013+
return items
1014+
.iter()
1015+
.filter_map(|v| {
1016+
if let serde_yaml::Value::String(s) = v {
1017+
Some(s.clone())
1018+
} else {
1019+
None
1020+
}
1021+
})
1022+
.collect();
1023+
}
1024+
}
1025+
vec![]
1026+
}
1027+
8571028
/// Move a note to a new folder.
8581029
pub fn move_note(
8591030
file: &str,
@@ -1493,4 +1664,150 @@ mod tests {
14931664
assert!(!updated.contains("Old body"));
14941665
drop(tmp);
14951666
}
1667+
1668+
#[test]
1669+
fn test_edit_frontmatter_add_tag() {
1670+
let (_tmp, store, root) = setup_vault();
1671+
let content = "---\ntags:\n - project\n---\n\n# Content\n";
1672+
std::fs::write(root.join("note.md"), content).unwrap();
1673+
store.insert_file("note.md", "hash", 100, &["project".to_string()], "efm123", None).unwrap();
1674+
1675+
let input = EditFrontmatterInput {
1676+
file: "note.md".into(),
1677+
operations: vec![FrontmatterOp::AddTag("rust".into())],
1678+
modified_by: "test".into(),
1679+
};
1680+
edit_frontmatter(&store, &root, &input).unwrap();
1681+
1682+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1683+
assert!(updated.contains("project"));
1684+
assert!(updated.contains("rust"));
1685+
}
1686+
1687+
#[test]
1688+
fn test_edit_frontmatter_remove_tag() {
1689+
let (_tmp, store, root) = setup_vault();
1690+
let content = "---\ntags:\n - project\n - old\n---\n\n# Content\n";
1691+
std::fs::write(root.join("note.md"), content).unwrap();
1692+
store.insert_file("note.md", "hash", 100, &["project".to_string(), "old".to_string()], "efm456", None).unwrap();
1693+
1694+
let input = EditFrontmatterInput {
1695+
file: "note.md".into(),
1696+
operations: vec![FrontmatterOp::RemoveTag("old".into())],
1697+
modified_by: "test".into(),
1698+
};
1699+
edit_frontmatter(&store, &root, &input).unwrap();
1700+
1701+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1702+
assert!(updated.contains("project"));
1703+
assert!(!updated.contains("old"));
1704+
}
1705+
1706+
#[test]
1707+
fn test_edit_frontmatter_set_property() {
1708+
let (_tmp, store, root) = setup_vault();
1709+
let content = "---\nstatus: draft\n---\n\n# Content\n";
1710+
std::fs::write(root.join("note.md"), content).unwrap();
1711+
store.insert_file("note.md", "hash", 100, &[], "efm789", None).unwrap();
1712+
1713+
let input = EditFrontmatterInput {
1714+
file: "note.md".into(),
1715+
operations: vec![FrontmatterOp::Set("status".into(), "active".into())],
1716+
modified_by: "test".into(),
1717+
};
1718+
edit_frontmatter(&store, &root, &input).unwrap();
1719+
1720+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1721+
assert!(updated.contains("status: active"));
1722+
assert!(!updated.contains("status: draft"));
1723+
}
1724+
1725+
#[test]
1726+
fn test_edit_frontmatter_remove_property() {
1727+
let (_tmp, store, root) = setup_vault();
1728+
let content = "---\nstatus: draft\ntitle: Test\n---\n\n# Content\n";
1729+
std::fs::write(root.join("note.md"), content).unwrap();
1730+
store.insert_file("note.md", "hash", 100, &[], "efmrm1", None).unwrap();
1731+
1732+
let input = EditFrontmatterInput {
1733+
file: "note.md".into(),
1734+
operations: vec![FrontmatterOp::Remove("status".into())],
1735+
modified_by: "test".into(),
1736+
};
1737+
edit_frontmatter(&store, &root, &input).unwrap();
1738+
1739+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1740+
assert!(!updated.contains("status"));
1741+
assert!(updated.contains("title: Test"));
1742+
}
1743+
1744+
#[test]
1745+
fn test_edit_frontmatter_add_alias() {
1746+
let (_tmp, store, root) = setup_vault();
1747+
let content = "---\ntags:\n - test\n---\n\n# Content\n";
1748+
std::fs::write(root.join("note.md"), content).unwrap();
1749+
store.insert_file("note.md", "hash", 100, &["test".to_string()], "efmal1", None).unwrap();
1750+
1751+
let input = EditFrontmatterInput {
1752+
file: "note.md".into(),
1753+
operations: vec![FrontmatterOp::AddAlias("My Alias".into())],
1754+
modified_by: "test".into(),
1755+
};
1756+
edit_frontmatter(&store, &root, &input).unwrap();
1757+
1758+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1759+
assert!(updated.contains("aliases"));
1760+
assert!(updated.contains("My Alias"));
1761+
}
1762+
1763+
#[test]
1764+
fn test_edit_frontmatter_no_existing_frontmatter() {
1765+
let (_tmp, store, root) = setup_vault();
1766+
let content = "# Content\n\nJust body, no frontmatter.\n";
1767+
std::fs::write(root.join("note.md"), content).unwrap();
1768+
store.insert_file("note.md", "hash", 100, &[], "efmnf1", None).unwrap();
1769+
1770+
let input = EditFrontmatterInput {
1771+
file: "note.md".into(),
1772+
operations: vec![
1773+
FrontmatterOp::Set("status".into(), "active".into()),
1774+
FrontmatterOp::AddTag("new-tag".into()),
1775+
],
1776+
modified_by: "test".into(),
1777+
};
1778+
edit_frontmatter(&store, &root, &input).unwrap();
1779+
1780+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1781+
assert!(updated.starts_with("---\n"));
1782+
assert!(updated.contains("status: active"));
1783+
assert!(updated.contains("new-tag"));
1784+
assert!(updated.contains("# Content"));
1785+
}
1786+
1787+
#[test]
1788+
fn test_edit_frontmatter_multiple_operations() {
1789+
let (_tmp, store, root) = setup_vault();
1790+
let content = "---\ntags:\n - old-tag\nstatus: draft\n---\n\n# Content\n";
1791+
std::fs::write(root.join("note.md"), content).unwrap();
1792+
store.insert_file("note.md", "hash", 100, &["old-tag".to_string()], "efmmo1", None).unwrap();
1793+
1794+
let input = EditFrontmatterInput {
1795+
file: "note.md".into(),
1796+
operations: vec![
1797+
FrontmatterOp::RemoveTag("old-tag".into()),
1798+
FrontmatterOp::AddTag("new-tag".into()),
1799+
FrontmatterOp::Set("status".into(), "active".into()),
1800+
FrontmatterOp::Set("priority".into(), "high".into()),
1801+
],
1802+
modified_by: "test".into(),
1803+
};
1804+
edit_frontmatter(&store, &root, &input).unwrap();
1805+
1806+
let updated = std::fs::read_to_string(root.join("note.md")).unwrap();
1807+
assert!(!updated.contains("old-tag"));
1808+
assert!(updated.contains("new-tag"));
1809+
assert!(updated.contains("status: active"));
1810+
assert!(updated.contains("priority: high"));
1811+
assert!(!updated.contains("status: draft"));
1812+
}
14961813
}

0 commit comments

Comments
 (0)