mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
feat: send nothing to TypeSafe when an import's header row holds an address, date or number, skip placeholder Column N headers, accept only the options each column was offered, never infer Subscribed, Categories or Email, prefer an exact existing field name before a folded match on the server as the client does, compute value kinds once per preview, and make Enter in the empty field search a no-op
This commit is contained in:
@@ -83,7 +83,7 @@ Mail over a daily budget is deferred, never dropped: the provider cursor is held
|
||||
| Copy judgment | The key alone | The subject and body of a campaign step, at Advisor evaluation and on the editor's re-check | `copy_judgments`, keyed by a hash of the copy and never exported |
|
||||
| Warmup content lint | The key alone | A generated warmup thread before it enters the bank | Rejected threads are counted on the generation job; nothing else is kept |
|
||||
| Bounce classification | The key alone | The bounce reason text, only when it does not name the recipient | `deliverability_events.metadata` |
|
||||
| Import column mapping | The key alone | For an import or Google Sheets preview, only the columns no header matched: every column header (a header that looks like data, such as an address, goes as its column number), the kind of value each column holds (email addresses, dates, short text and so on, worked out on the instance), and the workspace's custom field names. Never a cell value | Nothing is stored; the answer only fills in the suggested mapping |
|
||||
| Import column mapping | The key alone | For an import or Google Sheets preview with columns no header matched: every column header, the kind of value each column holds (email addresses, dates, short text and so on, worked out on the instance), and the workspace's custom field names. Never a cell value, and nothing at all when the header row holds an address, a date or a number, since that row is a contact rather than headers | Nothing is stored; the answer only fills in the suggested mapping |
|
||||
| Form triage | A per-form switch | The submitted answers, labelled by field; never the visitor's address, agent or URL | `form_submissions.triage`, which travels with the Contacts group |
|
||||
|
||||
See [Automatic inbox tagging](/guides/inbox-tagging/), [Advisor](/guides/advisor/), [Warmup](/guides/warmup/), [Deliverability](/guides/deliverability/), [Contacts](/guides/contacts-crm/#mapping-into-your-custom-fields) and [Forms](/guides/forms/).
|
||||
|
||||
@@ -27,7 +27,7 @@ The mapping menu lists the custom fields your workspace already has under **Your
|
||||
|
||||
A column whose header names an existing field is mapped to it before you open the menu. The match ignores case, spaces, underscores, dashes and dots, so a `company url` column lands on your `company_url` field in its stored spelling instead of starting a second field. Only one column is matched to each field, and standard fields (Email, Company, and so on) are matched first.
|
||||
|
||||
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. What is sent is the column headers, the kind of value each column holds and your field names, never a cell; see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
|
||||
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. It picks names, Company, Phone and your custom fields only: Subscribed and Categories change who gets mail and which categories exist, so they are never guessed. What is sent is the column headers, the kind of value each column holds and your field names, never a cell, and nothing at all for a file or sheet whose first row is a contact rather than headers; see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
|
||||
|
||||
Each custom mapping says what it will do: **Fills your existing field**, **Creates a new field**, or, when the name you typed differs from an existing field only in case or separators, **You already have `Industry`**, with **Use it** to switch to that field. **Keep N more as custom fields** uses the same matching, so the columns it claims land on your existing fields where it can.
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ func (s *contactService) ImportPreview(ctx context.Context, orgID uuid.UUID, r i
|
||||
// placed, so the mapper can ask for a second look at those.
|
||||
func (s *contactService) SuggestImportMapping(ctx context.Context, orgID uuid.UUID, headers []string, sample [][]string) ([]models.ContactImportColumnMapping, []int) {
|
||||
keys := s.existingCustomFieldKeys(ctx, orgID)
|
||||
suggested := SuggestMapping(headers, sample, keys)
|
||||
shapes := importmap.Shapes(len(headers), sample)
|
||||
suggested := suggestMapping(headers, sample, shapes, keys)
|
||||
if s.columnJudge == nil {
|
||||
return suggested, nil
|
||||
}
|
||||
return importmap.Infer(ctx, s.columnJudge, suggested, headers, importmap.Shapes(len(headers), sample), keys)
|
||||
return importmap.Infer(ctx, s.columnJudge, suggested, headers, shapes, keys)
|
||||
}
|
||||
|
||||
// existingCustomFieldKeys is the workspace's custom-field keys for the
|
||||
@@ -907,6 +908,10 @@ func padRow(row []string, n int) []string {
|
||||
// better than inventing a custom-field key the user didn't ask for. Every
|
||||
// importer that shows a column mapper calls this, so they suggest alike.
|
||||
func SuggestMapping(headers []string, sample [][]string, existingKeys []string) []models.ContactImportColumnMapping {
|
||||
return suggestMapping(headers, sample, importmap.Shapes(len(headers), sample), existingKeys)
|
||||
}
|
||||
|
||||
func suggestMapping(headers []string, sample [][]string, shapes []importmap.Shape, existingKeys []string) []models.ContactImportColumnMapping {
|
||||
out := make([]models.ContactImportColumnMapping, len(headers))
|
||||
for i, h := range headers {
|
||||
out[i] = guessTarget(i, h)
|
||||
@@ -940,22 +945,21 @@ func SuggestMapping(headers []string, sample [][]string, existingKeys []string)
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetVerificationStatus, VerificationProvider: provider}
|
||||
}
|
||||
matchExistingCustomFields(out, headers, existingKeys)
|
||||
matchEmailByValues(out, headers, sample)
|
||||
matchEmailByValues(out, shapes)
|
||||
return out
|
||||
}
|
||||
|
||||
// matchEmailByValues maps the first column of addresses to Email when no
|
||||
// header named it, so a file with "Work contact" or no header row at all
|
||||
// still has the one column an import cannot go without.
|
||||
func matchEmailByValues(out []models.ContactImportColumnMapping, headers []string, sample [][]string) {
|
||||
func matchEmailByValues(out []models.ContactImportColumnMapping, shapes []importmap.Shape) {
|
||||
for _, m := range out {
|
||||
if m.Target == models.ContactImportTargetEmail {
|
||||
return
|
||||
}
|
||||
}
|
||||
shapes := importmap.Shapes(len(headers), sample)
|
||||
for i := range out {
|
||||
if out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
|
||||
if i < len(shapes) && out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetEmail}
|
||||
return
|
||||
}
|
||||
@@ -963,15 +967,18 @@ func matchEmailByValues(out []models.ContactImportColumnMapping, headers []strin
|
||||
}
|
||||
|
||||
// matchExistingCustomFields maps each still-ignored column whose header names
|
||||
// an existing custom field, ignoring case and separators, onto that field's
|
||||
// stored spelling, so "industry" in a file lands on "Industry" instead of
|
||||
// starting a second field. Each field is claimed by one column at most.
|
||||
// an existing custom field onto that field's stored spelling: the exact name
|
||||
// first, then one differing only in case or separators, so "industry" in a
|
||||
// file lands on "Industry" instead of starting a second field. Each field is
|
||||
// claimed by one column at most. Mirrored by matchExistingKey in the web app.
|
||||
func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers, existingKeys []string) {
|
||||
if len(existingKeys) == 0 {
|
||||
return
|
||||
}
|
||||
exact := make(map[string]bool, len(existingKeys))
|
||||
byFold := make(map[string]string, len(existingKeys))
|
||||
for _, k := range existingKeys {
|
||||
exact[k] = true
|
||||
f := FoldCustomFieldKey(k)
|
||||
if _, taken := byFold[f]; f != "" && !taken {
|
||||
byFold[f] = k
|
||||
@@ -982,12 +989,17 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
|
||||
if out[i].Target != models.ContactImportTargetIgnore {
|
||||
continue
|
||||
}
|
||||
f := FoldCustomFieldKey(h)
|
||||
key, ok := byFold[f]
|
||||
if !ok || claimed[f] {
|
||||
key := utils.NormalizeJSONKey(h)
|
||||
if !exact[key] {
|
||||
var ok bool
|
||||
if key, ok = byFold[FoldCustomFieldKey(h)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if claimed[key] {
|
||||
continue
|
||||
}
|
||||
claimed[f] = true
|
||||
claimed[key] = true
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetCustom, CustomKey: key}
|
||||
}
|
||||
}
|
||||
@@ -998,7 +1010,7 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
|
||||
func FoldCustomFieldKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
|
||||
|
||||
want := []models.ContactImportColumnMapping{
|
||||
{Index: 0, Target: models.ContactImportTargetEmail},
|
||||
// The stored spelling wins, and the most used of two spellings.
|
||||
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
|
||||
// An exact name wins over a more used spelling of it.
|
||||
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "industry"},
|
||||
// Otherwise the stored spelling, the most used of several.
|
||||
{Index: 2, Target: models.ContactImportTargetCustom, CustomKey: "company_url"},
|
||||
// No field by that name: left for the user, never invented.
|
||||
{Index: 3, Target: models.ContactImportTargetIgnore},
|
||||
// A standard field outranks a custom field of the same name.
|
||||
{Index: 4, Target: models.ContactImportTargetCompany},
|
||||
// One column per field; a second spelling of it stays ignored.
|
||||
{Index: 5, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 5, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
|
||||
{Index: 6, Target: models.ContactImportTargetIgnore},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
@@ -35,6 +35,13 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingGivesEachFieldOneColumn(t *testing.T) {
|
||||
got := SuggestMapping([]string{"Email", "Industry", "INDUSTRY"}, nil, []string{"Industry"})
|
||||
if got[1].CustomKey != "Industry" || got[2].Target != models.ContactImportTargetIgnore {
|
||||
t.Fatalf("got %+v, want the second spelling left ignored", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingKeepsVerificationAheadOfCustomFields(t *testing.T) {
|
||||
headers := []string{"email", "ZeroBounce Status"}
|
||||
got := SuggestMapping(headers, [][]string{{"a@x.com", "valid"}}, []string{"ZeroBounce Status"})
|
||||
@@ -59,6 +66,7 @@ func TestFoldCustomFieldKey(t *testing.T) {
|
||||
"Revenue ($)": "revenue",
|
||||
"###": "",
|
||||
"Straße Nummer2": "straßenummer2",
|
||||
"Area m²": "aream²",
|
||||
} {
|
||||
if got := FoldCustomFieldKey(in); got != want {
|
||||
t.Errorf("FoldCustomFieldKey(%q) = %q, want %q", in, got, want)
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
// Package importmap asks TypeSafe which field an import column holds when its
|
||||
// header matched nothing, so "Job Title", "Firmenname" or "Sector" lands on the
|
||||
// right field before anyone opens a dropdown.
|
||||
//
|
||||
// THIS FILE IS THE POLICY: every question, option and threshold is here. It
|
||||
// follows the rules the inbox tagger learned the hard way:
|
||||
//
|
||||
// 1. One call per file. Every unmatched column is its own question over one
|
||||
// state, so the headers are ingested once.
|
||||
// 2. The model answers, code decides. Resolve applies the confidence floor
|
||||
// and gives each field to one column at most.
|
||||
// 3. Never ask what is already known. Columns the header aliases, the
|
||||
// verification vocabulary, an existing custom field or the values
|
||||
// themselves already decided are facts, and are never sent as questions.
|
||||
//
|
||||
// No cell value leaves the instance. The state is the headers, the workspace's
|
||||
// custom field names, and the kind of value each column holds (Shape).
|
||||
// Package importmap asks TypeSafe which field an import column holds when no
|
||||
// header rule placed it. The model answers, resolve decides, and the state
|
||||
// carries headers, value kinds and field names, never a cell.
|
||||
package importmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -53,22 +42,25 @@ const (
|
||||
// optionNone is the answer for a column that holds none of the fields offered.
|
||||
const optionNone = "none"
|
||||
|
||||
// standardOptions are the fields the model may pick, with what each holds.
|
||||
// Verification status is never offered: it is recognised from the values'
|
||||
// vocabulary, which the model is not shown.
|
||||
// standardOptions are the standard fields the model may pick, and the value
|
||||
// kinds each is offered for. Email is found by its values, and Subscribed and
|
||||
// Categories stay the user's call: a wrong guess there unsubscribes people or
|
||||
// mints categories from cell values.
|
||||
var standardOptions = []struct {
|
||||
target models.ContactImportColumnTarget
|
||||
desc string
|
||||
holds []Shape
|
||||
}{
|
||||
{models.ContactImportTargetEmail, "The contact's email address"},
|
||||
{models.ContactImportTargetFirstName, "The contact's first name, or given name"},
|
||||
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name"},
|
||||
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for"},
|
||||
{models.ContactImportTargetPhone, "The contact's phone or mobile number"},
|
||||
{models.ContactImportTargetSubscribed, "Whether the contact agreed to receive email, as yes or no"},
|
||||
{models.ContactImportTargetCategories, "Tags, labels, lists, or segments the contact belongs to"},
|
||||
{models.ContactImportTargetFirstName, "The contact's first name, or given name", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetPhone, "The contact's phone or mobile number", []Shape{ShapePhone, ShapeNumber, ShapeMixed}},
|
||||
}
|
||||
|
||||
// placeholderRe is the name a column gets when its header cell is blank or
|
||||
// the file has no header row; it says nothing about the column.
|
||||
var placeholderRe = regexp.MustCompile(`^Column [0-9]+$`)
|
||||
|
||||
// Column is one column as the model sees it.
|
||||
type Column struct {
|
||||
Header string `json:"header"`
|
||||
@@ -98,23 +90,35 @@ func Infer(
|
||||
shapes []Shape,
|
||||
existingKeys []string,
|
||||
) ([]models.ContactImportColumnMapping, []int) {
|
||||
if asker == nil {
|
||||
// A header row holding an address, a date or a number is a data row, so
|
||||
// sending it would send a contact.
|
||||
if asker == nil || headerRowIsData(headers) {
|
||||
return mapping, nil
|
||||
}
|
||||
taken := takenTargets(mapping)
|
||||
options, criteria := buildOptions(taken, existingKeys)
|
||||
options := buildOptions(taken, existingKeys)
|
||||
|
||||
st := state{Columns: make([]Column, len(headers)), CustomFields: capKeys(existingKeys)}
|
||||
questions := map[string]typesafe.Question{}
|
||||
for i := range headers {
|
||||
col := Column{Header: safeHeader(i, headers[i]), Holds: shapeAt(shapes, i)}
|
||||
offered := map[int]map[string]bool{}
|
||||
for i, h := range headers {
|
||||
col := Column{Header: capRunes(strings.TrimSpace(h), headerRunes), Holds: shapeAt(shapes, i)}
|
||||
st.Columns[i] = col
|
||||
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore || col.Holds == ShapeEmpty {
|
||||
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore ||
|
||||
col.Holds == ShapeEmpty || col.Header == "" || placeholderRe.MatchString(col.Header) {
|
||||
continue
|
||||
}
|
||||
criteria := columnCriteria(options, col.Holds)
|
||||
if len(criteria) <= 1 {
|
||||
continue
|
||||
}
|
||||
offered[i] = make(map[string]bool, len(criteria))
|
||||
for id := range criteria {
|
||||
offered[i][id] = true
|
||||
}
|
||||
questions[questionID(i)] = typesafe.Choice(
|
||||
"Which contact field does the column headed \""+col.Header+"\" hold? Its values are "+string(col.Holds)+".",
|
||||
columnCriteria(criteria, col.Holds),
|
||||
criteria,
|
||||
)
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
@@ -127,16 +131,18 @@ func Infer(
|
||||
if err != nil || resp == nil {
|
||||
return mapping, nil
|
||||
}
|
||||
return Resolve(mapping, resp.Answers, options, taken)
|
||||
return resolve(mapping, resp.Answers, options, offered, taken)
|
||||
}
|
||||
|
||||
// Resolve applies the answers: a confident choice of a field nobody fills yet
|
||||
// maps its column, and when two columns want one field the more confident
|
||||
// wins. It never touches a column the suggester already mapped.
|
||||
func Resolve(
|
||||
// resolve applies the answers: a confident choice, among the options that
|
||||
// column was offered, of a field nobody fills yet maps its column, and when
|
||||
// two columns want one field the more confident wins. It never touches a
|
||||
// column the suggester already mapped.
|
||||
func resolve(
|
||||
mapping []models.ContactImportColumnMapping,
|
||||
answers map[string]typesafe.Answer,
|
||||
options map[string]option,
|
||||
options map[string]offer,
|
||||
offered map[int]map[string]bool,
|
||||
taken map[string]bool,
|
||||
) ([]models.ContactImportColumnMapping, []int) {
|
||||
type candidate struct {
|
||||
@@ -146,19 +152,19 @@ func Resolve(
|
||||
conf float64
|
||||
}
|
||||
var cands []candidate
|
||||
for id, a := range answers {
|
||||
col, ok := columnOf(id)
|
||||
for qid, a := range answers {
|
||||
col, ok := columnOf(qid)
|
||||
if !ok || col >= len(mapping) || mapping[col].Target != models.ContactImportTargetIgnore {
|
||||
continue
|
||||
}
|
||||
if a.Confidence < ConfFloor || a.Choice == optionNone {
|
||||
if a.Confidence < ConfFloor || a.Choice == optionNone || !offered[col][a.Choice] {
|
||||
continue
|
||||
}
|
||||
opt, ok := options[a.Choice]
|
||||
o, ok := options[a.Choice]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, candidate{col: col, opt: opt, id: identity(opt), conf: a.Confidence})
|
||||
cands = append(cands, candidate{col: col, opt: o.option, id: identity(o.option), conf: a.Confidence})
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
if cands[i].conf != cands[j].conf {
|
||||
@@ -174,12 +180,10 @@ func Resolve(
|
||||
}
|
||||
var inferred []int
|
||||
for _, c := range cands {
|
||||
if c.id != "" && claimed[c.id] {
|
||||
if claimed[c.id] {
|
||||
continue
|
||||
}
|
||||
if c.id != "" {
|
||||
claimed[c.id] = true
|
||||
}
|
||||
claimed[c.id] = true
|
||||
out[c.col] = models.ContactImportColumnMapping{Index: c.col, Target: c.opt.target, CustomKey: c.opt.customKey}
|
||||
inferred = append(inferred, c.col)
|
||||
}
|
||||
@@ -187,49 +191,58 @@ func Resolve(
|
||||
return out, inferred
|
||||
}
|
||||
|
||||
// offer is one option with what it says to the model and which value kinds
|
||||
// it is offered for (nil: every kind).
|
||||
type offer struct {
|
||||
option
|
||||
desc string
|
||||
holds []Shape
|
||||
}
|
||||
|
||||
// buildOptions lists the fields still free, under short ids so no field name
|
||||
// has to survive as an option key. criteria holds each id's description.
|
||||
func buildOptions(taken map[string]bool, existingKeys []string) (map[string]option, map[string]string) {
|
||||
options := map[string]option{}
|
||||
criteria := map[string]string{}
|
||||
// has to survive as an option key.
|
||||
func buildOptions(taken map[string]bool, existingKeys []string) map[string]offer {
|
||||
options := map[string]offer{}
|
||||
for _, s := range standardOptions {
|
||||
o := option{target: s.target}
|
||||
if taken[identity(o)] {
|
||||
continue
|
||||
if !taken[identity(o)] {
|
||||
options[string(s.target)] = offer{option: o, desc: s.desc, holds: s.holds}
|
||||
}
|
||||
id := string(s.target)
|
||||
options[id] = o
|
||||
criteria[id] = s.desc
|
||||
}
|
||||
for i, k := range capKeys(existingKeys) {
|
||||
o := option{target: models.ContactImportTargetCustom, customKey: k}
|
||||
if taken[identity(o)] {
|
||||
continue
|
||||
if !taken[identity(o)] {
|
||||
options["field_"+strconv.Itoa(i+1)] = offer{option: o, desc: "The workspace's existing custom field named \"" + k + "\""}
|
||||
}
|
||||
id := "field_" + strconv.Itoa(i+1)
|
||||
options[id] = o
|
||||
criteria[id] = "The workspace's existing custom field named \"" + k + "\""
|
||||
}
|
||||
criteria[optionNone] = "None of these: the column holds something else"
|
||||
return options, criteria
|
||||
return options
|
||||
}
|
||||
|
||||
// columnCriteria drops the options a column's values rule out, so a column of
|
||||
// URLs is never offered Subscribed.
|
||||
func columnCriteria(all map[string]string, holds Shape) map[string]string {
|
||||
out := make(map[string]string, len(all))
|
||||
for id, desc := range all {
|
||||
if id == string(models.ContactImportTargetSubscribed) && holds != ShapeYesNo {
|
||||
continue
|
||||
// columnCriteria is what one column is asked to choose from: the options its
|
||||
// value kind allows, and none.
|
||||
func columnCriteria(options map[string]offer, holds Shape) map[string]string {
|
||||
out := map[string]string{optionNone: "None of these: the column holds something else"}
|
||||
for id, o := range options {
|
||||
if o.holds == nil || slices.Contains(o.holds, holds) {
|
||||
out[id] = o.desc
|
||||
}
|
||||
if id == string(models.ContactImportTargetEmail) && holds != ShapeEmail {
|
||||
continue
|
||||
}
|
||||
out[id] = desc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// headerRowIsData reports whether any header cell is shaped like a value
|
||||
// rather than a name.
|
||||
func headerRowIsData(headers []string) bool {
|
||||
for _, h := range headers {
|
||||
switch ShapeOf([]string{h}) {
|
||||
case ShapeText, ShapeLongText, ShapeEmpty:
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// takenTargets is every destination the suggestion already fills.
|
||||
func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
|
||||
taken := map[string]bool{}
|
||||
@@ -242,11 +255,11 @@ func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
|
||||
return taken
|
||||
}
|
||||
|
||||
// identity names where an option writes; "" for targets that take any number
|
||||
// of columns (categories) or none (ignore).
|
||||
// identity names where an option writes. Every target Infer can pick holds
|
||||
// one column's value.
|
||||
func identity(o option) string {
|
||||
switch o.target {
|
||||
case models.ContactImportTargetIgnore, models.ContactImportTargetCategories, "":
|
||||
case models.ContactImportTargetIgnore, "":
|
||||
return ""
|
||||
case models.ContactImportTargetCustom:
|
||||
return "custom:" + o.customKey
|
||||
@@ -264,16 +277,11 @@ func capKeys(keys []string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// safeHeader is the header as sent. A header row that is really a data row
|
||||
// ("dana@acme.com") is never sent: the column goes by its number instead.
|
||||
func safeHeader(i int, h string) string {
|
||||
if ShapeOf([]string{h}) != ShapeText {
|
||||
return "Column " + strconv.Itoa(i+1)
|
||||
func capRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
if utf8.RuneCountInString(h) > headerRunes {
|
||||
h = string([]rune(h)[:headerRunes])
|
||||
}
|
||||
return h
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
func shapeAt(shapes []Shape, i int) Shape {
|
||||
|
||||
@@ -49,10 +49,10 @@ func optionFor(t *testing.T, q typesafe.Question, fragment string) string {
|
||||
}
|
||||
|
||||
func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "dana@acme.com"}
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "Column 5"}
|
||||
sample := [][]string{
|
||||
{"dana@acme.com", "Head of Sales", "Acme BV", "", "lee@beta.io"},
|
||||
{"lee@beta.io", "CTO", "Beta GmbH", "", "kim@gamma.io"},
|
||||
{"dana@acme.com", "Head of Sales", "Acme BV", "", "Warm"},
|
||||
{"lee@beta.io", "CTO", "Beta GmbH", "", "Cold"},
|
||||
}
|
||||
mapping := ignored(len(headers))
|
||||
mapping[0].Target = models.ContactImportTargetEmail
|
||||
@@ -63,34 +63,44 @@ func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
|
||||
if f.calls != 1 {
|
||||
t.Fatalf("want one call per file, got %d", f.calls)
|
||||
}
|
||||
for _, id := range []string{"column_2", "column_3", "column_5"} {
|
||||
for _, id := range []string{"column_2", "column_3"} {
|
||||
if _, ok := f.questions[id]; !ok {
|
||||
t.Errorf("missing question %s", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"column_1", "column_4"} {
|
||||
// Mapped, empty, and a placeholder name that says nothing about the column.
|
||||
for _, id := range []string{"column_1", "column_4", "column_5"} {
|
||||
if _, ok := f.questions[id]; ok {
|
||||
t.Errorf("asked about %s, which is mapped or empty", id)
|
||||
t.Errorf("asked about %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(f.state)
|
||||
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO"} {
|
||||
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO", "Warm"} {
|
||||
if strings.Contains(string(raw), cell) {
|
||||
t.Errorf("state carries the cell value %q: %s", cell, raw)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(raw), `"Column 5"`) {
|
||||
t.Errorf("a header that is really data must go by its number: %s", raw)
|
||||
}
|
||||
if strings.Contains(f.questions["column_5"].Instructions, "@") {
|
||||
t.Errorf("the question repeats a data-shaped header: %s", f.questions["column_5"].Instructions)
|
||||
}
|
||||
|
||||
func TestInferSendsNothingWhenTheHeaderRowIsData(t *testing.T) {
|
||||
// A sheet with no header row: its first contact became the headers.
|
||||
for _, headers := range [][]string{
|
||||
{"dana@acme.com", "Dana", "Reyes", "Acme BV"},
|
||||
{"Email", "Dana", "2026-09-05", "Acme BV"},
|
||||
} {
|
||||
f := &fakeAsker{}
|
||||
got, inferred := Infer(context.Background(), f, ignored(len(headers)), headers,
|
||||
[]Shape{ShapeEmail, ShapeText, ShapeText, ShapeText}, nil)
|
||||
if f.calls != 0 || inferred != nil || got[1].Target != models.ContactImportTargetIgnore {
|
||||
t.Fatalf("header row %q reached the judge", headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Opt-in", "Notes"}
|
||||
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference"}}
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Newsletter", "Notes", "Mobil"}
|
||||
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference", "+49 151 2345678"}}
|
||||
mapping := ignored(len(headers))
|
||||
mapping[0].Target = models.ContactImportTargetEmail
|
||||
|
||||
@@ -98,14 +108,18 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
probe := &fakeAsker{answers: map[string]typesafe.Answer{}}
|
||||
Infer(context.Background(), probe, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
|
||||
title := optionFor(t, probe.questions["column_2"], `"Title"`)
|
||||
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetEmail)]; offered {
|
||||
t.Errorf("Email is already filled and must not be offered")
|
||||
for id, q := range probe.questions {
|
||||
crit := q.Criteria.(map[string]string)
|
||||
for _, never := range []models.ContactImportColumnTarget{
|
||||
models.ContactImportTargetEmail, models.ContactImportTargetSubscribed, models.ContactImportTargetCategories,
|
||||
} {
|
||||
if _, offered := crit[string(never)]; offered {
|
||||
t.Errorf("%s offers %s, which stays the user's call", id, never)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; offered {
|
||||
t.Errorf("Subscribed is offered for a column that is not yes or no")
|
||||
}
|
||||
if _, offered := probe.questions["column_5"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; !offered {
|
||||
t.Errorf("Subscribed is not offered for a yes or no column")
|
||||
if _, offered := probe.questions["column_7"].Criteria.(map[string]string)[string(models.ContactImportTargetFirstName)]; offered {
|
||||
t.Errorf("a column of phone numbers is offered First name")
|
||||
}
|
||||
|
||||
f := &fakeAsker{answers: map[string]typesafe.Answer{
|
||||
@@ -113,10 +127,12 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
// Two columns want Company: the more confident one gets it.
|
||||
"column_3": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.95},
|
||||
"column_4": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.80},
|
||||
// Under the floor: left for the user.
|
||||
"column_5": {Choice: string(models.ContactImportTargetSubscribed), Confidence: 0.55},
|
||||
// Offered to text columns, never to this yes or no one.
|
||||
"column_5": {Choice: string(models.ContactImportTargetLastName), Confidence: 0.99},
|
||||
"column_6": {Choice: optionNone, Confidence: 0.99},
|
||||
// Never asked, and an option nobody offered: both ignored.
|
||||
// Under the floor: left for the user.
|
||||
"column_7": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.55},
|
||||
// Never asked, and an id nobody offered: both ignored.
|
||||
"column_1": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.99},
|
||||
"column_9": {Choice: "made_up", Confidence: 0.99},
|
||||
}}
|
||||
@@ -129,6 +145,7 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
{Index: 3, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 4, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 5, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 6, Target: models.ContactImportTargetIgnore},
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
@@ -141,6 +158,26 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
if mapping[1].Target != models.ContactImportTargetIgnore {
|
||||
t.Errorf("Infer changed the caller's mapping in place")
|
||||
}
|
||||
|
||||
// A different field that no other column wants is still applied.
|
||||
f.answers["column_7"] = typesafe.Answer{Choice: string(models.ContactImportTargetPhone), Confidence: 0.9}
|
||||
got, _ = Infer(context.Background(), f, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
|
||||
if got[6].Target != models.ContactImportTargetPhone {
|
||||
t.Errorf("phone column: got %+v", got[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferSendsLongHeadersCapped(t *testing.T) {
|
||||
long := "What is the primary reason your company is evaluating outreach tools this quarter, in a few words?"
|
||||
f := &fakeAsker{}
|
||||
Infer(context.Background(), f, ignored(2), []string{"Email", long}, []Shape{ShapeEmail, ShapeText}, nil)
|
||||
q, ok := f.questions["column_2"]
|
||||
if !ok {
|
||||
t.Fatalf("a long header was not asked about")
|
||||
}
|
||||
if strings.Contains(q.Instructions, long) || !strings.Contains(q.Instructions, long[:headerRunes]) {
|
||||
t.Errorf("header not capped at %d runes: %s", headerRunes, q.Instructions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferFallsBackToTheSuggestion(t *testing.T) {
|
||||
|
||||
@@ -800,8 +800,14 @@ export function TargetPicker({
|
||||
onChange({ index: value.index, target: "custom", custom_key: name });
|
||||
}
|
||||
|
||||
// Enter takes an exact name first, then the first match; with nothing
|
||||
// typed it does nothing, so it can never remap a column by accident.
|
||||
function pickFirst() {
|
||||
if (standard.length > 0) pickStandard(standard[0].id);
|
||||
if (q === "") return;
|
||||
const exactStd = standard.find((t) => t.label.toLowerCase() === q.toLowerCase());
|
||||
if (exactStd) pickStandard(exactStd.id);
|
||||
else if (existingKeys.includes(q)) pickExisting(q);
|
||||
else if (standard.length > 0) pickStandard(standard[0].id);
|
||||
else if (custom.length > 0) pickExisting(custom[0]);
|
||||
else if (canCreate) pickNew(q);
|
||||
else return;
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("existing custom fields", () => {
|
||||
it("folds the way the server does", () => {
|
||||
expect(foldCustomKey(" Company.URL ")).toBe("companyurl");
|
||||
expect(foldCustomKey("Revenue ($)")).toBe("revenue");
|
||||
expect(foldCustomKey("Area m²")).toBe("aream²");
|
||||
});
|
||||
|
||||
it("reads both spellings of a custom mapping", () => {
|
||||
|
||||
Reference in New Issue
Block a user