mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
163 lines
3.9 KiB
Go
163 lines
3.9 KiB
Go
package importmap
|
|
|
|
import (
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/warmbly/warmbly/internal/email"
|
|
)
|
|
|
|
// Shape is the kind of value a column holds, worked out here from the sample
|
|
// so the model can tell a column apart without reading anyone's data.
|
|
type Shape string
|
|
|
|
const (
|
|
ShapeEmpty Shape = "empty"
|
|
ShapeEmail Shape = "email addresses"
|
|
ShapeURL Shape = "web addresses"
|
|
ShapePhone Shape = "phone numbers"
|
|
ShapeNumber Shape = "numbers"
|
|
ShapeDate Shape = "dates or times"
|
|
ShapeYesNo Shape = "yes or no values"
|
|
ShapeText Shape = "short text"
|
|
ShapeLongText Shape = "long text"
|
|
ShapeMixed Shape = "a mix of kinds"
|
|
)
|
|
|
|
// dominantShare is how much of a column one kind must cover to name it.
|
|
const dominantShare = 0.8
|
|
|
|
// longTextRunes is where a cell stops being a name or a label.
|
|
const longTextRunes = 60
|
|
|
|
var (
|
|
phoneRe = regexp.MustCompile(`^\+?\(?[0-9][0-9 ().\-]{5,}[0-9]$`)
|
|
numberRe = regexp.MustCompile(`^[-+]?[$€£]?[0-9][0-9,. ]*%?$`)
|
|
)
|
|
|
|
var dateLayouts = []string{
|
|
time.RFC3339, "2006-01-02", "2006-01-02 15:04:05", "2006-01-02 15:04", "01/02/2006", "02/01/2006",
|
|
"1/2/2006", "2/1/2006", "02.01.2006", "Jan 02, 2006 3:04 PM", "Jan 2, 2006", "2 Jan 2006", "January 2, 2006",
|
|
}
|
|
|
|
var yesNo = map[string]bool{
|
|
"yes": true, "no": true, "true": true, "false": true, "y": true, "n": true,
|
|
"subscribed": true, "unsubscribed": true, "opted in": true, "opted out": true,
|
|
}
|
|
|
|
// ShapeOf names the kind of the non-empty values, or ShapeMixed when no kind
|
|
// covers most of them.
|
|
func ShapeOf(values []string) Shape {
|
|
counts := map[Shape]int{}
|
|
n := 0
|
|
for _, v := range values {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
counts[valueShape(v)]++
|
|
n++
|
|
}
|
|
if n == 0 {
|
|
return ShapeEmpty
|
|
}
|
|
best, bestN := ShapeMixed, 0
|
|
for s, c := range counts {
|
|
if c > bestN || (c == bestN && s < best) {
|
|
best, bestN = s, c
|
|
}
|
|
}
|
|
if float64(bestN) < dominantShare*float64(n) {
|
|
return ShapeMixed
|
|
}
|
|
return best
|
|
}
|
|
|
|
// Shapes is ShapeOf for every column of a sample.
|
|
func Shapes(width int, sample [][]string) []Shape {
|
|
out := make([]Shape, width)
|
|
col := make([]string, 0, len(sample))
|
|
for i := range out {
|
|
col = col[:0]
|
|
for _, row := range sample {
|
|
if i < len(row) {
|
|
col = append(col, row[i])
|
|
}
|
|
}
|
|
out[i] = ShapeOf(col)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func valueShape(v string) Shape {
|
|
lower := strings.ToLower(v)
|
|
switch {
|
|
case yesNo[lower]:
|
|
return ShapeYesNo
|
|
case strings.Contains(v, "@") && email.IsValid(extractAddress(v)):
|
|
return ShapeEmail
|
|
case isURL(lower):
|
|
return ShapeURL
|
|
case isDate(v):
|
|
return ShapeDate
|
|
case isPhone(v):
|
|
return ShapePhone
|
|
case numberRe.MatchString(v):
|
|
if _, err := strconv.ParseFloat(strings.NewReplacer(",", "", " ", "", "$", "", "€", "", "£", "", "%", "").Replace(v), 64); err == nil {
|
|
return ShapeNumber
|
|
}
|
|
}
|
|
if utf8.RuneCountInString(v) > longTextRunes {
|
|
return ShapeLongText
|
|
}
|
|
return ShapeText
|
|
}
|
|
|
|
// extractAddress reads "Dana Reyes <dana@acme.com>" the way the importer does.
|
|
func extractAddress(v string) string {
|
|
if a, ok := email.Normalize(v); ok {
|
|
return a
|
|
}
|
|
return v
|
|
}
|
|
|
|
func isURL(v string) bool {
|
|
if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") || strings.HasPrefix(v, "www.") {
|
|
u, err := url.Parse(v)
|
|
return err == nil && (u.Host != "" || strings.HasPrefix(v, "www."))
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isPhone wants the punctuation people write numbers with (a leading +,
|
|
// brackets, spaces or dashes), so a bare 10-digit count stays a number.
|
|
func isPhone(v string) bool {
|
|
if !phoneRe.MatchString(v) || digits(v) < 7 {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(v, "+") || strings.ContainsAny(v, " ()-")
|
|
}
|
|
|
|
func isDate(v string) bool {
|
|
for _, l := range dateLayouts {
|
|
if _, err := time.Parse(l, v); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func digits(v string) int {
|
|
n := 0
|
|
for _, r := range v {
|
|
if r >= '0' && r <= '9' {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|