github.com/qri-io/qri@v0.10.1-0.20220104210721-c771715036cb/base/friendly/friendly.go (about) 1 package friendly 2 3 import ( 4 "fmt" 5 "strings" 6 7 golog "github.com/ipfs/go-log" 8 "github.com/qri-io/deepdiff" 9 ) 10 11 var log = golog.Logger("friendly") 12 13 const smallNumberOfChangesToBody = 3 14 15 // ComponentChanges holds state when building a diff message 16 type ComponentChanges struct { 17 EntireMessage string 18 Num int 19 Size int 20 Rows []string 21 } 22 23 // DiffDescriptions creates a friendly message from diff operations. If there's no differences 24 // found, return empty strings. 25 func DiffDescriptions(headDeltas, bodyDeltas []*deepdiff.Delta, bodyStats *deepdiff.Stats, assumeBodyChanged bool) (string, string) { 26 log.Debugw("DiffDescriptions", "len(headDeltas)", len(headDeltas), "len(bodyDeltas)", len(bodyDeltas), "bodyStats", bodyStats, "assumeBodyChanged", assumeBodyChanged) 27 if len(headDeltas) == 0 && len(bodyDeltas) == 0 { 28 return "", "" 29 } 30 31 headDeltas = preprocess(headDeltas, "") 32 bodyDeltas = preprocess(bodyDeltas, "") 33 34 perComponentChanges := buildComponentChanges(headDeltas, bodyDeltas, bodyStats, assumeBodyChanged) 35 36 // Data accumulated while iterating over the components. 37 shortTitle := "" 38 longMessage := "" 39 changedComponents := []string{} 40 41 // Iterate over certain components that we want to check for changes to. 42 componentsToCheck := []string{"meta", "structure", "readme", "viz", "transform", "body"} 43 for _, compName := range componentsToCheck { 44 if changes, ok := perComponentChanges[compName]; ok { 45 log.Debugw("checking component", "name", compName) 46 changedComponents = append(changedComponents, compName) 47 48 // Decide heuristically which type of message to use for this component 49 var msg string 50 if changes.EntireMessage != "" { 51 // If there's a single message that describes the change for this component, 52 // use that. Currently only used for deletes. 53 msg = fmt.Sprintf("%s %s", compName, changes.EntireMessage) 54 shortTitle = msg 55 } else if compName == "body" { 56 if changes.Rows == nil { 57 // Body works specially. If a significant number of changes have been made, 58 // just report the percentage of the body that has changed. 59 // Take the max of left and right to calculate the percentage change. 60 divisor := bodyStats.Left 61 if bodyStats.Right > divisor { 62 divisor = bodyStats.Right 63 } 64 percentChange := int(100.0 * changes.Size / divisor) 65 action := fmt.Sprintf("changed by %d%%", percentChange) 66 msg = fmt.Sprintf("%s:\n\t%s", compName, action) 67 shortTitle = fmt.Sprintf("%s %s", compName, action) 68 } else { 69 // If only a small number of changes were made, then describe each of them. 70 msg = fmt.Sprintf("%s:", compName) 71 for _, r := range changes.Rows { 72 msg = fmt.Sprintf("%s\n\t%s", msg, r) 73 } 74 shortTitle = fmt.Sprintf("%s %s", compName, strings.Join(changes.Rows, " and ")) 75 } 76 } else { 77 if len(changes.Rows) == 0 { 78 // This should never happen. If a component has no changes, it should not have 79 // a key in the perComponentChanges map. 80 log.Errorf("for %s: changes.Row is zero-sized", compName) 81 } else if len(changes.Rows) == 1 { 82 // For any other component, if there's only one change, directly describe 83 // it for both the long message and short title. 84 msg = fmt.Sprintf("%s:\n\t%s", compName, changes.Rows[0]) 85 shortTitle = fmt.Sprintf("%s %s", compName, changes.Rows[0]) 86 } else { 87 // If there were multiple changes, describe them all for the long message 88 // but just show the number of changes for the short title. 89 msg = fmt.Sprintf("%s:", compName) 90 for _, r := range changes.Rows { 91 msg = fmt.Sprintf("%s\n\t%s", msg, r) 92 } 93 shortTitle = fmt.Sprintf("%s updated %d fields", compName, len(changes.Rows)) 94 } 95 } 96 // Append to full long message 97 if longMessage == "" { 98 longMessage = msg 99 } else { 100 longMessage = fmt.Sprintf("%s\n%s", longMessage, msg) 101 } 102 } 103 } 104 105 // Check if there were 2 or more components that got changed. If so, the short title will 106 // just list the names of those components, with no additional detail. 107 if len(changedComponents) == 2 { 108 shortTitle = fmt.Sprintf("updated %s and %s", changedComponents[0], changedComponents[1]) 109 } else if len(changedComponents) > 2 { 110 text := "updated " 111 for k, compName := range changedComponents { 112 if k == len(changedComponents)-1 { 113 // If last change in the list... 114 text = fmt.Sprintf("%sand %s", text, compName) 115 } else { 116 text = fmt.Sprintf("%s%s, ", text, compName) 117 } 118 } 119 shortTitle = text 120 } 121 122 return shortTitle, longMessage 123 } 124 125 const dtReplace = deepdiff.Operation("replace") 126 127 // preprocess makes delta lists easier to work with, by combining operations 128 // when possible & removing unwanted paths 129 func preprocess(deltas deepdiff.Deltas, path string) deepdiff.Deltas { 130 build := make([]*deepdiff.Delta, 0, len(deltas)) 131 for i, d := range deltas { 132 if i > 0 { 133 last := build[len(build)-1] 134 if last.Path.String() == d.Path.String() { 135 if last.Type == deepdiff.DTDelete && d.Type == deepdiff.DTInsert { 136 last.Type = dtReplace 137 continue 138 } 139 } 140 } 141 build = append(build, d) 142 if len(d.Deltas) > 0 { 143 d.Deltas = preprocess(d.Deltas, joinPath(path, d.Path.String())) 144 } 145 } 146 return build 147 } 148 149 func buildComponentChanges(headDeltas, bodyDeltas deepdiff.Deltas, bodyStats *deepdiff.Stats, assumeBodyChanged bool) map[string]*ComponentChanges { 150 perComponentChanges := make(map[string]*ComponentChanges) 151 for _, d := range headDeltas { 152 compName := d.Path.String() 153 if d.Type != deepdiff.DTContext { 154 // Entire component changed 155 if d.Type == deepdiff.DTInsert || d.Type == deepdiff.DTDelete || d.Type == dtReplace { 156 if _, ok := perComponentChanges[compName]; !ok { 157 perComponentChanges[compName] = &ComponentChanges{} 158 } 159 changes := perComponentChanges[compName] 160 changes.EntireMessage = pastTense(string(d.Type)) 161 continue 162 } else { 163 log.Debugf("unknown delta type %q for path %q", d.Type, d.Path) 164 continue 165 } 166 } else if len(d.Deltas) > 0 { 167 // Part of the component changed, record some state to build into a message later 168 changes := &ComponentChanges{} 169 buildChanges(changes, "", d.Deltas) 170 perComponentChanges[compName] = changes 171 } 172 } 173 if assumeBodyChanged { 174 perComponentChanges["body"] = &ComponentChanges{EntireMessage: "changed"} 175 } else if len(bodyDeltas) > 0 && bodyStats != nil { 176 bodyChanges := &ComponentChanges{} 177 buildBodyChanges(bodyChanges, "", bodyDeltas) 178 if bodyChanges.Num > 0 { 179 perComponentChanges["body"] = bodyChanges 180 } 181 } 182 return perComponentChanges 183 } 184 185 func buildChanges(changes *ComponentChanges, parentPath string, deltas deepdiff.Deltas) { 186 for _, d := range deltas { 187 if d.Type != deepdiff.DTContext { 188 rowModify := fmt.Sprintf("%s %s", pastTense(string(d.Type)), joinPath(parentPath, d.Path.String())) 189 changes.Rows = append(changes.Rows, rowModify) 190 } 191 192 if len(d.Deltas) > 0 { 193 buildChanges(changes, joinPath(parentPath, d.Path.String()), d.Deltas) 194 } 195 } 196 } 197 198 func buildBodyChanges(changes *ComponentChanges, parentPath string, deltas deepdiff.Deltas) { 199 for _, d := range deltas { 200 if d.Type == deepdiff.DTDelete || d.Type == deepdiff.DTInsert || d.Type == deepdiff.DTUpdate || d.Type == dtReplace { 201 changes.Num++ 202 if valArray, ok := d.Value.([]interface{}); ok { 203 changes.Size += len(valArray) + 1 204 } else if valMap, ok := d.Value.(map[string]interface{}); ok { 205 changes.Size += len(valMap) + 1 206 } else { 207 changes.Size++ 208 } 209 if changes.Num <= smallNumberOfChangesToBody { 210 rowModify := fmt.Sprintf("%s row %s", pastTense(string(d.Type)), joinPath(parentPath, d.Path.String())) 211 changes.Rows = append(changes.Rows, rowModify) 212 } else { 213 changes.Rows = nil 214 } 215 } else if len(d.Deltas) > 0 { 216 buildBodyChanges(changes, joinPath(parentPath, d.Path.String()), d.Deltas) 217 } 218 } 219 } 220 221 func joinPath(parent, element string) string { 222 if parent == "" { 223 return element 224 } 225 return fmt.Sprintf("%s.%s", parent, element) 226 } 227 228 func pastTense(text string) string { 229 if text == string(deepdiff.DTDelete) { 230 return "removed" 231 } else if text == string(deepdiff.DTInsert) { 232 return "added" 233 } else if text == string(deepdiff.DTUpdate) { 234 return "updated" 235 } else if text == "replace" { 236 return "updated" 237 } 238 return text 239 }