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 }