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  }