Add Tabular Diff for CSV files (#14661)
Implements request #14320 The rendering of CSV files does match the diff style. * Moved CSV logic into base package. * Added method to create a tabular diff. * Added CSV compare context. * Added CSV diff template. * Use new table style in CSV markup. * Added file size limit for CSV rendering. * Display CSV parser errors in diff. * Lazy read single file. * Lazy read rows for full diff. * Added unit tests for various CSV changes.forgejo
parent
d3b8127ad3
commit
0c6137617f
@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package csv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/translation"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var quoteRegexp = regexp.MustCompile(`["'][\s\S]+?["']`)
|
||||||
|
|
||||||
|
// CreateReader creates a csv.Reader with the given delimiter.
|
||||||
|
func CreateReader(rawBytes []byte, delimiter rune) *csv.Reader {
|
||||||
|
rd := csv.NewReader(bytes.NewReader(rawBytes))
|
||||||
|
rd.Comma = delimiter
|
||||||
|
rd.TrimLeadingSpace = true
|
||||||
|
return rd
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReaderAndGuessDelimiter tries to guess the field delimiter from the content and creates a csv.Reader.
|
||||||
|
func CreateReaderAndGuessDelimiter(rawBytes []byte) *csv.Reader {
|
||||||
|
delimiter := guessDelimiter(rawBytes)
|
||||||
|
return CreateReader(rawBytes, delimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guessDelimiter scores the input CSV data against delimiters, and returns the best match.
|
||||||
|
// Reads at most 10k bytes & 10 lines.
|
||||||
|
func guessDelimiter(data []byte) rune {
|
||||||
|
maxLines := 10
|
||||||
|
maxBytes := util.Min(len(data), 1e4)
|
||||||
|
text := string(data[:maxBytes])
|
||||||
|
text = quoteRegexp.ReplaceAllLiteralString(text, "")
|
||||||
|
lines := strings.SplitN(text, "\n", maxLines+1)
|
||||||
|
lines = lines[:util.Min(maxLines, len(lines))]
|
||||||
|
|
||||||
|
delimiters := []rune{',', ';', '\t', '|', '@'}
|
||||||
|
bestDelim := delimiters[0]
|
||||||
|
bestScore := 0.0
|
||||||
|
for _, delim := range delimiters {
|
||||||
|
score := scoreDelimiter(lines, delim)
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestDelim = delim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestDelim
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreDelimiter uses a count & regularity metric to evaluate a delimiter against lines of CSV.
|
||||||
|
func scoreDelimiter(lines []string, delim rune) float64 {
|
||||||
|
countTotal := 0
|
||||||
|
countLineMax := 0
|
||||||
|
linesNotEqual := 0
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
countLine := strings.Count(line, string(delim))
|
||||||
|
countTotal += countLine
|
||||||
|
if countLine != countLineMax {
|
||||||
|
if countLineMax != 0 {
|
||||||
|
linesNotEqual++
|
||||||
|
}
|
||||||
|
countLineMax = util.Max(countLine, countLineMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(countTotal) * (1 - float64(linesNotEqual)/float64(len(lines)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatError converts csv errors into readable messages.
|
||||||
|
func FormatError(err error, locale translation.Locale) (string, error) {
|
||||||
|
var perr *csv.ParseError
|
||||||
|
if errors.As(err, &perr) {
|
||||||
|
if perr.Err == csv.ErrFieldCount {
|
||||||
|
return locale.Tr("repo.error.csv.invalid_field_count", perr.Line), nil
|
||||||
|
}
|
||||||
|
return locale.Tr("repo.error.csv.unexpected", perr.Line, perr.Column), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", err
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package csv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateReader(t *testing.T) {
|
||||||
|
rd := CreateReader([]byte{}, ',')
|
||||||
|
assert.Equal(t, ',', rd.Comma)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReaderAndGuessDelimiter(t *testing.T) {
|
||||||
|
input := "a;b;c\n1;2;3\n4;5;6"
|
||||||
|
|
||||||
|
rd := CreateReaderAndGuessDelimiter([]byte(input))
|
||||||
|
assert.Equal(t, ';', rd.Comma)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuessDelimiter(t *testing.T) {
|
||||||
|
var kases = map[string]rune{
|
||||||
|
"a": ',',
|
||||||
|
"1,2": ',',
|
||||||
|
"1;2": ';',
|
||||||
|
"1\t2": '\t',
|
||||||
|
"1|2": '|',
|
||||||
|
"1,2,3;4,5,6;7,8,9\na;b;c": ';',
|
||||||
|
"\"1,2,3,4\";\"a\nb\"\nc;d": ';',
|
||||||
|
"<br/>": ',',
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range kases {
|
||||||
|
assert.EqualValues(t, guessDelimiter([]byte(k)), v)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,379 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitdiff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const unmappedColumn = -1
|
||||||
|
const maxRowsToInspect int = 10
|
||||||
|
const minRatioToMatch float32 = 0.8
|
||||||
|
|
||||||
|
// TableDiffCellType represents the type of a TableDiffCell.
|
||||||
|
type TableDiffCellType uint8
|
||||||
|
|
||||||
|
// TableDiffCellType possible values.
|
||||||
|
const (
|
||||||
|
TableDiffCellEqual TableDiffCellType = iota + 1
|
||||||
|
TableDiffCellChanged
|
||||||
|
TableDiffCellAdd
|
||||||
|
TableDiffCellDel
|
||||||
|
)
|
||||||
|
|
||||||
|
// TableDiffCell represents a cell of a TableDiffRow
|
||||||
|
type TableDiffCell struct {
|
||||||
|
LeftCell string
|
||||||
|
RightCell string
|
||||||
|
Type TableDiffCellType
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableDiffRow represents a row of a TableDiffSection.
|
||||||
|
type TableDiffRow struct {
|
||||||
|
RowIdx int
|
||||||
|
Cells []*TableDiffCell
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableDiffSection represents a section of a DiffFile.
|
||||||
|
type TableDiffSection struct {
|
||||||
|
Rows []*TableDiffRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// csvReader wraps a csv.Reader which buffers the first rows.
|
||||||
|
type csvReader struct {
|
||||||
|
reader *csv.Reader
|
||||||
|
buffer [][]string
|
||||||
|
line int
|
||||||
|
eof bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCsvReader creates a csvReader and fills the buffer
|
||||||
|
func createCsvReader(reader *csv.Reader, bufferRowCount int) (*csvReader, error) {
|
||||||
|
csv := &csvReader{reader: reader}
|
||||||
|
csv.buffer = make([][]string, bufferRowCount)
|
||||||
|
for i := 0; i < bufferRowCount && !csv.eof; i++ {
|
||||||
|
row, err := csv.readNextRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
csv.buffer[i] = row
|
||||||
|
}
|
||||||
|
csv.line = bufferRowCount
|
||||||
|
return csv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRow gets a row from the buffer if present or advances the reader to the requested row. On the end of the file only nil gets returned.
|
||||||
|
func (csv *csvReader) GetRow(row int) ([]string, error) {
|
||||||
|
if row < len(csv.buffer) {
|
||||||
|
return csv.buffer[row], nil
|
||||||
|
}
|
||||||
|
if csv.eof {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
fields, err := csv.readNextRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if csv.eof {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
csv.line++
|
||||||
|
if csv.line-1 == row {
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (csv *csvReader) readNextRow() ([]string, error) {
|
||||||
|
if csv.eof {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
row, err := csv.reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
csv.eof = true
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCsvDiff creates a tabular diff based on two CSV readers.
|
||||||
|
func CreateCsvDiff(diffFile *DiffFile, baseReader *csv.Reader, headReader *csv.Reader) ([]*TableDiffSection, error) {
|
||||||
|
if baseReader != nil && headReader != nil {
|
||||||
|
return createCsvDiff(diffFile, baseReader, headReader)
|
||||||
|
}
|
||||||
|
|
||||||
|
if baseReader != nil {
|
||||||
|
return createCsvDiffSingle(baseReader, TableDiffCellDel)
|
||||||
|
}
|
||||||
|
return createCsvDiffSingle(headReader, TableDiffCellAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCsvDiffSingle creates a tabular diff based on a single CSV reader. All cells are added or deleted.
|
||||||
|
func createCsvDiffSingle(reader *csv.Reader, celltype TableDiffCellType) ([]*TableDiffSection, error) {
|
||||||
|
var rows []*TableDiffRow
|
||||||
|
i := 1
|
||||||
|
for {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cells := make([]*TableDiffCell, len(row))
|
||||||
|
for j := 0; j < len(row); j++ {
|
||||||
|
cells[j] = &TableDiffCell{LeftCell: row[j], Type: celltype}
|
||||||
|
}
|
||||||
|
rows = append(rows, &TableDiffRow{RowIdx: i, Cells: cells})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*TableDiffSection{{Rows: rows}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCsvDiff(diffFile *DiffFile, baseReader *csv.Reader, headReader *csv.Reader) ([]*TableDiffSection, error) {
|
||||||
|
a, err := createCsvReader(baseReader, maxRowsToInspect)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := createCsvReader(headReader, maxRowsToInspect)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a2b, b2a := getColumnMapping(a, b)
|
||||||
|
|
||||||
|
columns := len(a2b) + countUnmappedColumns(b2a)
|
||||||
|
if len(a2b) < len(b2a) {
|
||||||
|
columns = len(b2a) + countUnmappedColumns(a2b)
|
||||||
|
}
|
||||||
|
|
||||||
|
createDiffRow := func(aline int, bline int) (*TableDiffRow, error) {
|
||||||
|
cells := make([]*TableDiffCell, columns)
|
||||||
|
|
||||||
|
if aline == 0 || bline == 0 {
|
||||||
|
var (
|
||||||
|
row []string
|
||||||
|
celltype TableDiffCellType
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if bline == 0 {
|
||||||
|
row, err = a.GetRow(aline - 1)
|
||||||
|
celltype = TableDiffCellDel
|
||||||
|
} else {
|
||||||
|
row, err = b.GetRow(bline - 1)
|
||||||
|
celltype = TableDiffCellAdd
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for i := 0; i < len(row); i++ {
|
||||||
|
cells[i] = &TableDiffCell{LeftCell: row[i], Type: celltype}
|
||||||
|
}
|
||||||
|
return &TableDiffRow{RowIdx: bline, Cells: cells}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
arow, err := a.GetRow(aline - 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
brow, err := b.GetRow(bline - 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(arow) == 0 && len(brow) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(a2b); i++ {
|
||||||
|
acell, _ := getCell(arow, i)
|
||||||
|
if a2b[i] == unmappedColumn {
|
||||||
|
cells[i] = &TableDiffCell{LeftCell: acell, Type: TableDiffCellDel}
|
||||||
|
} else {
|
||||||
|
bcell, _ := getCell(brow, a2b[i])
|
||||||
|
|
||||||
|
celltype := TableDiffCellChanged
|
||||||
|
if acell == bcell {
|
||||||
|
celltype = TableDiffCellEqual
|
||||||
|
}
|
||||||
|
|
||||||
|
cells[i] = &TableDiffCell{LeftCell: acell, RightCell: bcell, Type: celltype}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(b2a); i++ {
|
||||||
|
if b2a[i] == unmappedColumn {
|
||||||
|
bcell, _ := getCell(brow, i)
|
||||||
|
cells[i] = &TableDiffCell{LeftCell: bcell, Type: TableDiffCellAdd}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TableDiffRow{RowIdx: bline, Cells: cells}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sections []*TableDiffSection
|
||||||
|
|
||||||
|
for i, section := range diffFile.Sections {
|
||||||
|
var rows []*TableDiffRow
|
||||||
|
lines := tryMergeLines(section.Lines)
|
||||||
|
for j, line := range lines {
|
||||||
|
if i == 0 && j == 0 && (line[0] != 1 || line[1] != 1) {
|
||||||
|
diffRow, err := createDiffRow(1, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if diffRow != nil {
|
||||||
|
rows = append(rows, diffRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diffRow, err := createDiffRow(line[0], line[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if diffRow != nil {
|
||||||
|
rows = append(rows, diffRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) > 0 {
|
||||||
|
sections = append(sections, &TableDiffSection{Rows: rows})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnMapping creates a mapping of columns between a and b
|
||||||
|
func getColumnMapping(a *csvReader, b *csvReader) ([]int, []int) {
|
||||||
|
arow, _ := a.GetRow(0)
|
||||||
|
brow, _ := b.GetRow(0)
|
||||||
|
|
||||||
|
a2b := []int{}
|
||||||
|
b2a := []int{}
|
||||||
|
|
||||||
|
if arow != nil {
|
||||||
|
a2b = make([]int, len(arow))
|
||||||
|
}
|
||||||
|
if brow != nil {
|
||||||
|
b2a = make([]int, len(brow))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(b2a); i++ {
|
||||||
|
b2a[i] = unmappedColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
bcol := 0
|
||||||
|
for i := 0; i < len(a2b); i++ {
|
||||||
|
a2b[i] = unmappedColumn
|
||||||
|
|
||||||
|
acell, ea := getCell(arow, i)
|
||||||
|
if ea == nil {
|
||||||
|
for j := bcol; j < len(b2a); j++ {
|
||||||
|
bcell, eb := getCell(brow, j)
|
||||||
|
if eb == nil && acell == bcell {
|
||||||
|
a2b[i] = j
|
||||||
|
b2a[j] = i
|
||||||
|
bcol = j + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryMapColumnsByContent(a, a2b, b, b2a)
|
||||||
|
tryMapColumnsByContent(b, b2a, a, a2b)
|
||||||
|
|
||||||
|
return a2b, b2a
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryMapColumnsByContent tries to map missing columns by the content of the first lines.
|
||||||
|
func tryMapColumnsByContent(a *csvReader, a2b []int, b *csvReader, b2a []int) {
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(a2b); i++ {
|
||||||
|
if a2b[i] == unmappedColumn {
|
||||||
|
if b2a[start] == unmappedColumn {
|
||||||
|
rows := util.Min(maxRowsToInspect, util.Max(0, util.Min(len(a.buffer), len(b.buffer))-1))
|
||||||
|
same := 0
|
||||||
|
for j := 1; j <= rows; j++ {
|
||||||
|
acell, ea := getCell(a.buffer[j], i)
|
||||||
|
bcell, eb := getCell(b.buffer[j], start+1)
|
||||||
|
if ea == nil && eb == nil && acell == bcell {
|
||||||
|
same++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (float32(same) / float32(rows)) > minRatioToMatch {
|
||||||
|
a2b[i] = start + 1
|
||||||
|
b2a[start+1] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = a2b[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCell returns the specific cell or nil if not present.
|
||||||
|
func getCell(row []string, column int) (string, error) {
|
||||||
|
if column < len(row) {
|
||||||
|
return row[column], nil
|
||||||
|
}
|
||||||
|
return "", errors.New("Undefined column")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countUnmappedColumns returns the count of unmapped columns.
|
||||||
|
func countUnmappedColumns(mapping []int) int {
|
||||||
|
count := 0
|
||||||
|
for i := 0; i < len(mapping); i++ {
|
||||||
|
if mapping[i] == unmappedColumn {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryMergeLines maps the separated line numbers of a git diff. The result is assumed to be ordered.
|
||||||
|
func tryMergeLines(lines []*DiffLine) [][2]int {
|
||||||
|
ids := make([][2]int, len(lines))
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
if line.Type != DiffLineSection {
|
||||||
|
ids[i][0] = line.LeftIdx
|
||||||
|
ids[i][1] = line.RightIdx
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = ids[:i]
|
||||||
|
|
||||||
|
result := make([][2]int, len(ids))
|
||||||
|
|
||||||
|
j := 0
|
||||||
|
for i = 0; i < len(ids); i++ {
|
||||||
|
if ids[i][0] == 0 {
|
||||||
|
if j > 0 && result[j-1][1] == 0 {
|
||||||
|
temp := j
|
||||||
|
for temp > 0 && result[temp-1][1] == 0 {
|
||||||
|
temp--
|
||||||
|
}
|
||||||
|
result[temp][1] = ids[i][1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[j] = ids[i]
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[:j]
|
||||||
|
}
|
@ -0,0 +1,119 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitdiff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
csv_module "code.gitea.io/gitea/modules/csv"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCSVDiff(t *testing.T) {
|
||||||
|
var cases = []struct {
|
||||||
|
diff string
|
||||||
|
base string
|
||||||
|
head string
|
||||||
|
cells [][2]TableDiffCellType
|
||||||
|
}{
|
||||||
|
// case 0
|
||||||
|
{
|
||||||
|
diff: `diff --git a/unittest.csv b/unittest.csv
|
||||||
|
--- a/unittest.csv
|
||||||
|
+++ b/unittest.csv
|
||||||
|
@@ -0,0 +1,2 @@
|
||||||
|
+col1,col2
|
||||||
|
+a,a`,
|
||||||
|
base: "",
|
||||||
|
head: "col1,col2\na,a",
|
||||||
|
cells: [][2]TableDiffCellType{{TableDiffCellAdd, TableDiffCellAdd}, {TableDiffCellAdd, TableDiffCellAdd}},
|
||||||
|
},
|
||||||
|
// case 1
|
||||||
|
{
|
||||||
|
diff: `diff --git a/unittest.csv b/unittest.csv
|
||||||
|
--- a/unittest.csv
|
||||||
|
+++ b/unittest.csv
|
||||||
|
@@ -1,2 +1,3 @@
|
||||||
|
col1,col2
|
||||||
|
-a,a
|
||||||
|
+a,a
|
||||||
|
+b,b`,
|
||||||
|
base: "col1,col2\na,a",
|
||||||
|
head: "col1,col2\na,a\nb,b",
|
||||||
|
cells: [][2]TableDiffCellType{{TableDiffCellEqual, TableDiffCellEqual}, {TableDiffCellEqual, TableDiffCellEqual}, {TableDiffCellAdd, TableDiffCellAdd}},
|
||||||
|
},
|
||||||
|
// case 2
|
||||||
|
{
|
||||||
|
diff: `diff --git a/unittest.csv b/unittest.csv
|
||||||
|
--- a/unittest.csv
|
||||||
|
+++ b/unittest.csv
|
||||||
|
@@ -1,3 +1,2 @@
|
||||||
|
col1,col2
|
||||||
|
-a,a
|
||||||
|
b,b`,
|
||||||
|
base: "col1,col2\na,a\nb,b",
|
||||||
|
head: "col1,col2\nb,b",
|
||||||
|
cells: [][2]TableDiffCellType{{TableDiffCellEqual, TableDiffCellEqual}, {TableDiffCellDel, TableDiffCellDel}, {TableDiffCellEqual, TableDiffCellEqual}},
|
||||||
|
},
|
||||||
|
// case 3
|
||||||
|
{
|
||||||
|
diff: `diff --git a/unittest.csv b/unittest.csv
|
||||||
|
--- a/unittest.csv
|
||||||
|
+++ b/unittest.csv
|
||||||
|
@@ -1,2 +1,2 @@
|
||||||
|
col1,col2
|
||||||
|
-b,b
|
||||||
|
+b,c`,
|
||||||
|
base: "col1,col2\nb,b",
|
||||||
|
head: "col1,col2\nb,c",
|
||||||
|
cells: [][2]TableDiffCellType{{TableDiffCellEqual, TableDiffCellEqual}, {TableDiffCellEqual, TableDiffCellChanged}},
|
||||||
|
},
|
||||||
|
// case 4
|
||||||
|
{
|
||||||
|
diff: `diff --git a/unittest.csv b/unittest.csv
|
||||||
|
--- a/unittest.csv
|
||||||
|
+++ b/unittest.csv
|
||||||
|
@@ -1,2 +0,0 @@
|
||||||
|
-col1,col2
|
||||||
|
-b,c`,
|
||||||
|
base: "col1,col2\nb,c",
|
||||||
|
head: "",
|
||||||
|
cells: [][2]TableDiffCellType{{TableDiffCellDel, TableDiffCellDel}, {TableDiffCellDel, TableDiffCellDel}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for n, c := range cases {
|
||||||
|
diff, err := ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.diff))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParsePatch failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseReader *csv.Reader
|
||||||
|
if len(c.base) > 0 {
|
||||||
|
baseReader = csv_module.CreateReaderAndGuessDelimiter([]byte(c.base))
|
||||||
|
}
|
||||||
|
var headReader *csv.Reader
|
||||||
|
if len(c.head) > 0 {
|
||||||
|
headReader = csv_module.CreateReaderAndGuessDelimiter([]byte(c.head))
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreateCsvDiff(diff.Files[0], baseReader, headReader)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(result), "case %d: should be one section", n)
|
||||||
|
|
||||||
|
section := result[0]
|
||||||
|
assert.Equal(t, len(c.cells), len(section.Rows), "case %d: should be %d rows", n, len(c.cells))
|
||||||
|
|
||||||
|
for i, row := range section.Rows {
|
||||||
|
assert.Equal(t, 2, len(row.Cells), "case %d: row %d should have two cells", n, i)
|
||||||
|
for j, cell := range row.Cells {
|
||||||
|
assert.Equal(t, c.cells[i][j], cell.Type, "case %d: row %d cell %d should be equal", n, i, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{$result := call .root.CreateCsvDiff .file .root.BaseCommit .root.HeadCommit}}
|
||||||
|
{{if $result.Error}}
|
||||||
|
<div class="ui center">{{$result.Error}}</div>
|
||||||
|
{{else if $result.Sections}}
|
||||||
|
<table class="data-table">
|
||||||
|
{{range $i, $section := $result.Sections}}
|
||||||
|
<tbody {{if gt $i 0}}class="section"{{end}}>
|
||||||
|
{{range $j, $row := $section.Rows}}
|
||||||
|
<tr>
|
||||||
|
{{if and (eq $i 0) (eq $j 0)}}
|
||||||
|
<th class="line-num">{{.RowIdx}}</th>
|
||||||
|
{{range $j, $cell := $row.Cells}}
|
||||||
|
{{if eq $cell.Type 2}}
|
||||||
|
<th class="modified"><span class="removed-code">{{.LeftCell}}</span> <span class="added-code">{{.RightCell}}</span></th>
|
||||||
|
{{else if eq $cell.Type 3}}
|
||||||
|
<th class="added"><span class="added-code">{{.LeftCell}}</span></th>
|
||||||
|
{{else if eq $cell.Type 4}}
|
||||||
|
<th class="removed"><span class="removed-code">{{.LeftCell}}</span></th>
|
||||||
|
{{else}}
|
||||||
|
<th>{{.RightCell}}</th>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<td class="line-num">{{if .RowIdx}}{{.RowIdx}}{{end}}</td>
|
||||||
|
{{range $j, $cell := $row.Cells}}
|
||||||
|
{{if eq $cell.Type 2}}
|
||||||
|
<td class="modified"><span class="removed-code">{{.LeftCell}}</span> <span class="added-code">{{.RightCell}}</span></td>
|
||||||
|
{{else if eq $cell.Type 3}}
|
||||||
|
<td class="added"><span class="added-code">{{.LeftCell}}</span></td>
|
||||||
|
{{else if eq $cell.Type 4}}
|
||||||
|
<td class="removed"><span class="removed-code">{{.LeftCell}}</span></td>
|
||||||
|
{{else}}
|
||||||
|
<td>{{.RightCell}}</td>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
Loading…
Reference in New Issue