github.com/gitbundle/modules@v0.0.0-20231025071548-85b91c5c3b01/csv/csv.go (about)

     1  // Copyright 2023 The GitBundle Inc. All rights reserved.
     2  // Copyright 2017 The Gitea Authors. All rights reserved.
     3  // Use of this source code is governed by a MIT-style
     4  // license that can be found in the LICENSE file.
     5  
     6  package csv
     7  
     8  import (
     9  	"bytes"
    10  	stdcsv "encoding/csv"
    11  	"io"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  
    16  	"github.com/gitbundle/modules/markup"
    17  	"github.com/gitbundle/modules/translation"
    18  	"github.com/gitbundle/modules/util"
    19  )
    20  
    21  const (
    22  	maxLines        = 10
    23  	guessSampleSize = 1e4 // 10k
    24  )
    25  
    26  // CreateReader creates a csv.Reader with the given delimiter.
    27  func CreateReader(input io.Reader, delimiter rune) *stdcsv.Reader {
    28  	rd := stdcsv.NewReader(input)
    29  	rd.Comma = delimiter
    30  	if delimiter != '\t' && delimiter != ' ' {
    31  		// TrimLeadingSpace can't be true when delimiter is a tab or a space as the value for a column might be empty,
    32  		// thus would change `\t\t` to just `\t` or `  ` (two spaces) to just ` ` (single space)
    33  		rd.TrimLeadingSpace = true
    34  	}
    35  	return rd
    36  }
    37  
    38  // CreateReaderAndDetermineDelimiter tries to guess the field delimiter from the content and creates a csv.Reader.
    39  // Reads at most guessSampleSize bytes.
    40  func CreateReaderAndDetermineDelimiter(ctx *markup.RenderContext, rd io.Reader) (*stdcsv.Reader, error) {
    41  	data := make([]byte, guessSampleSize)
    42  	size, err := util.ReadAtMost(rd, data)
    43  	if err != nil {
    44  		return nil, err
    45  	}
    46  
    47  	return CreateReader(
    48  		io.MultiReader(bytes.NewReader(data[:size]), rd),
    49  		determineDelimiter(ctx, data[:size]),
    50  	), nil
    51  }
    52  
    53  // determineDelimiter takes a RenderContext and if it isn't nil and the Filename has an extension that specifies the delimiter,
    54  // it is used as the delimiter. Otherwise we call guessDelimiter with the data passed
    55  func determineDelimiter(ctx *markup.RenderContext, data []byte) rune {
    56  	extension := ".csv"
    57  	if ctx != nil {
    58  		extension = strings.ToLower(filepath.Ext(ctx.RelativePath))
    59  	}
    60  
    61  	var delimiter rune
    62  	switch extension {
    63  	case ".tsv":
    64  		delimiter = '\t'
    65  	case ".psv":
    66  		delimiter = '|'
    67  	default:
    68  		delimiter = guessDelimiter(data)
    69  	}
    70  
    71  	return delimiter
    72  }
    73  
    74  // quoteRegexp follows the RFC-4180 CSV standard for when double-quotes are used to enclose fields, then a double-quote appearing inside a
    75  // field must be escaped by preceding it with another double quote. https://www.ietf.org/rfc/rfc4180.txt
    76  // This finds all quoted strings that have escaped quotes.
    77  var quoteRegexp = regexp.MustCompile(`"[^"]*"`)
    78  
    79  // removeQuotedStrings uses the quoteRegexp to remove all quoted strings so that we can reliably have each row on one line
    80  // (quoted strings often have new lines within the string)
    81  func removeQuotedString(text string) string {
    82  	return quoteRegexp.ReplaceAllLiteralString(text, "")
    83  }
    84  
    85  // guessDelimiter takes up to maxLines of the CSV text, iterates through the possible delimiters, and sees if the CSV Reader reads it without throwing any errors.
    86  // If more than one delimiter passes, the delimiter that results in the most columns is returned.
    87  func guessDelimiter(data []byte) rune {
    88  	delimiter := guessFromBeforeAfterQuotes(data)
    89  	if delimiter != 0 {
    90  		return delimiter
    91  	}
    92  
    93  	// Removes quoted values so we don't have columns with new lines in them
    94  	text := removeQuotedString(string(data))
    95  
    96  	// Make the text just be maxLines or less, ignoring truncated lines
    97  	lines := strings.SplitN(text, "\n", maxLines+1) // Will contain at least one line, and if there are more than MaxLines, the last item holds the rest of the lines
    98  	if len(lines) > maxLines {
    99  		// If the length of lines is > maxLines we know we have the max number of lines, trim it to maxLines
   100  		lines = lines[:maxLines]
   101  	} else if len(lines) > 1 && len(data) >= guessSampleSize {
   102  		// Even with data >= guessSampleSize, we don't have maxLines + 1 (no extra lines, must have really long lines)
   103  		// thus the last line is probably have a truncated line. Drop the last line if len(lines) > 1
   104  		lines = lines[:len(lines)-1]
   105  	}
   106  
   107  	// Put lines back together as a string
   108  	text = strings.Join(lines, "\n")
   109  
   110  	delimiters := []rune{',', '\t', ';', '|', '@'}
   111  	validDelim := delimiters[0]
   112  	validDelimColCount := 0
   113  	for _, delim := range delimiters {
   114  		csvReader := stdcsv.NewReader(strings.NewReader(text))
   115  		csvReader.Comma = delim
   116  		if rows, err := csvReader.ReadAll(); err == nil && len(rows) > 0 && len(rows[0]) > validDelimColCount {
   117  			validDelim = delim
   118  			validDelimColCount = len(rows[0])
   119  		}
   120  	}
   121  	return validDelim
   122  }
   123  
   124  // FormatError converts csv errors into readable messages.
   125  func FormatError(err error, locale translation.Locale) (string, error) {
   126  	if perr, ok := err.(*stdcsv.ParseError); ok {
   127  		if perr.Err == stdcsv.ErrFieldCount {
   128  			return locale.Tr("repo.error.csv.invalid_field_count", perr.Line), nil
   129  		}
   130  		return locale.Tr("repo.error.csv.unexpected", perr.Line, perr.Column), nil
   131  	}
   132  
   133  	return "", err
   134  }
   135  
   136  // Looks for possible delimiters right before or after (with spaces after the former) double quotes with closing quotes
   137  var beforeAfterQuotes = regexp.MustCompile(`([,@\t;|]{0,1}) *(?:"[^"]*")+([,@\t;|]{0,1})`)
   138  
   139  // guessFromBeforeAfterQuotes guesses the limiter by finding a double quote that has a valid delimiter before it and a closing quote,
   140  // or a double quote with a closing quote and a valid delimiter after it
   141  func guessFromBeforeAfterQuotes(data []byte) rune {
   142  	rs := beforeAfterQuotes.FindStringSubmatch(string(data)) // returns first match, or nil if none
   143  	if rs != nil {
   144  		if rs[1] != "" {
   145  			return rune(rs[1][0]) // delimiter found left of quoted string
   146  		} else if rs[2] != "" {
   147  			return rune(rs[2][0]) // delimiter found right of quoted string
   148  		}
   149  	}
   150  	return 0 // no match found
   151  }