@@ -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 ) ]
7693pub 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.
8581029pub 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 = "---\n tags:\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 = "---\n tags:\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 = "---\n status: 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 = "---\n status: draft\n title: 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 = "---\n tags:\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 \n Just 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 = "---\n tags:\n - old-tag\n status: 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