You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

csv.go 5.4KB

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