diff --git a/pkg/carddav/sync.go b/pkg/carddav/sync.go index b51afd6..4c582fc 100644 --- a/pkg/carddav/sync.go +++ b/pkg/carddav/sync.go @@ -15,10 +15,6 @@ import ( func Sync(client Client, records []map[string]interface{}, vCardV3 bool, vCardPhotoSize int) error { - reOrderPhotoAttrs := regexp.MustCompile(`(TYPE=[A-Z]+);(ENCODING=\w)`) - rePhotoEncodingAttr := regexp.MustCompile(`ENCODING=(\w);`) - rePhoneAttr := regexp.MustCompile(`TEL;TYPE=(\w+)`) - existingItemsAll, err := client.GetAll() if err != nil { return errors.Wrap(err, "failed to list current vcards") @@ -47,6 +43,7 @@ func Sync(client Client, records []map[string]interface{}, vCardV3 bool, vCardPh idString := id.Value doneIDs[idString] = true + var ok bool expectedRecord, ok := recordsByID[idString] if !ok { err = client.Delete(idString) @@ -56,66 +53,35 @@ func Sync(client Client, records []map[string]interface{}, vCardV3 bool, vCardPh continue } - expectedVcardString, err := vcard.Generate( - []map[string]interface{}{expectedRecord}, + needsUpdate := false + note := existingCard.Get(govcard.FieldNote) + if note == nil { + needsUpdate = true + } else { + lines := strings.Split(note.Value, "\n") + var lastLine string + if len(lines) > 0 { + lastLine = strings.TrimSpace(lines[len(lines)-1]) + } + if lastLine != vcard.CRC32Hash(expectedRecord) { + needsUpdate = true + } + } + + if !needsUpdate { + continue + } + + err := updateVcard( + client, + expectedRecord, + existingCard, vCardV3, vCardPhotoSize, idString, ) if err != nil { - return errors.Wrap(err, "failed to generate new vcard") - } - - buf := bytes.NewBufferString("") - enc := govcard.NewEncoder(buf) - err = enc.Encode(existingCard) - if err != nil { - return errors.Wrap(err, "failed to encode current vcard") - } - - existingVcardString := strings.TrimSpace(buf.String()) - - existingVcardString = reOrderPhotoAttrs.ReplaceAllString(existingVcardString, "$2;$1") - existingVcardString = rePhotoEncodingAttr.ReplaceAllString(existingVcardString, "ENCODING=b;") - existingVcardString = rePhoneAttr.ReplaceAllStringFunc(existingVcardString, func(s string) string { - parts := rePhoneAttr.FindStringSubmatch(s) - return fmt.Sprintf("TEL;TYPE=%s", strings.ToLower(parts[1])) - }) - - existingLines := strings.Split(strings.TrimSpace(existingVcardString), "\n") - sort.Slice(existingLines, func(i, j int) bool { - return existingLines[i] < existingLines[j] - }) - expectedLines := strings.Split(strings.TrimSpace(expectedVcardString), "\n") - sort.Slice(expectedLines, func(i, j int) bool { - return expectedLines[i] < expectedLines[j] - }) - - shouldUpdate := false - if len(existingLines) != len(expectedLines) { - shouldUpdate = true - } - if strings.Join(existingLines, "") != strings.Join(expectedLines, "") { - shouldUpdate = true - } - - if shouldUpdate { - fmt.Println("updating", idString) - - if len(existingLines) == len(expectedLines) { - for i, line := range existingLines { - if line != expectedLines[i] { - fmt.Println("line", i, "differs") - fmt.Println("old", firstN(line, 100)) - fmt.Println("new", firstN(expectedLines[i], 100)) - } - } - } - - err = client.Put(idString, expectedVcardString) - if err != nil { - return errors.Wrap(err, "failed to update vcard") - } + return errors.Wrap(err, "failed to update vcard") } } @@ -127,11 +93,12 @@ func Sync(client Client, records []map[string]interface{}, vCardV3 bool, vCardPh fmt.Println("creating", id) expectedVcardString, err := vcard.Generate( - []map[string]interface{}{rec}, + rec, vCardV3, vCardPhotoSize, id, ) + expectedVcardString = normalizeVcard(expectedVcardString) err = client.Put(id, expectedVcardString) if err != nil { @@ -142,6 +109,92 @@ func Sync(client Client, records []map[string]interface{}, vCardV3 bool, vCardPh return nil } +func updateVcard( + client Client, + expectedRecord map[string]interface{}, + existingCard govcard.Card, + vCardV3 bool, + vCardPhotoSize int, + idString string, +) error { + expectedVcardString, err := vcard.Generate( + expectedRecord, + vCardV3, + vCardPhotoSize, + idString, + ) + if err != nil { + return errors.Wrap(err, "failed to generate new vcard") + } + + expectedVcardString = normalizeVcard(expectedVcardString) + + var existingVcardString string + buf := bytes.NewBufferString("") + enc := govcard.NewEncoder(buf) + err = enc.Encode(existingCard) + if err != nil { + return errors.Wrap(err, "failed to encode current vcard") + } + + existingVcardString = normalizeVcard(buf.String()) + + existingLines := strings.Split(strings.TrimSpace(existingVcardString), "\n") + sort.Slice(existingLines, func(i, j int) bool { + return existingLines[i] < existingLines[j] + }) + expectedLines := strings.Split(strings.TrimSpace(expectedVcardString), "\n") + sort.Slice(expectedLines, func(i, j int) bool { + return expectedLines[i] < expectedLines[j] + }) + + shouldUpdate := false + if len(existingLines) != len(expectedLines) { + shouldUpdate = true + } + if strings.Join(existingLines, "") != strings.Join(expectedLines, "") { + shouldUpdate = true + } + + if shouldUpdate { + fmt.Println("updating", idString) + + if len(existingLines) == len(expectedLines) { + for i, line := range existingLines { + if line != expectedLines[i] { + fmt.Println("line", i, "differs") + fmt.Println("old", firstN(line, 100)) + fmt.Println("new", firstN(expectedLines[i], 100)) + } + } + } + + err = client.Put(idString, expectedVcardString) + if err != nil { + return errors.Wrap(err, "failed to update vcard") + } + } + + return nil +} + +func normalizeVcard(vcardString string) string { + reOrderPhotoAttrs := regexp.MustCompile(`(ENCODING=\w);(TYPE=[A-Z]+)`) + rePhoneAttr := regexp.MustCompile(`TEL;TYPE=(\w+)`) + + newVcardString := strings.TrimSpace(vcardString) + + newVcardString = reOrderPhotoAttrs.ReplaceAllString(newVcardString, "$2;$1") + newVcardString = rePhoneAttr.ReplaceAllStringFunc(newVcardString, func(s string) string { + parts := rePhoneAttr.FindStringSubmatch(s) + return fmt.Sprintf("TEL;TYPE=%s", strings.ToLower(parts[1])) + }) + + newVcardString = strings.ReplaceAll(newVcardString, "ENCODING=b", "ENCODING=B") + + return newVcardString +} + func firstN(str string, n int) string { v := []rune(str) if n >= len(v) { diff --git a/pkg/vcard/generate.go b/pkg/vcard/generate.go index 19a083e..99574ce 100644 --- a/pkg/vcard/generate.go +++ b/pkg/vcard/generate.go @@ -5,175 +5,185 @@ import ( "encoding/base64" "encoding/json" "fmt" - "image" + "hash/crc32" "image/jpeg" "net/http" + "regexp" "strings" "github.com/disintegration/imaging" govcard "github.com/emersion/go-vcard" ) -func Generate(contacts []map[string]interface{}, useV3 bool, photoSize int, id string) (string, error) { +func CRC32Hash(input any) string { + str := fmt.Sprintf("%v", input) + return fmt.Sprintf("%d", crc32.ChecksumIEEE([]byte(str))) +} + +func Generate(contact map[string]interface{}, useV3 bool, photoSize int, id string) (string, error) { + buf := bytes.NewBufferString("") enc := govcard.NewEncoder(buf) - for _, contact := range contacts { - card := make(govcard.Card) + card := make(govcard.Card) - // set the name and formatted name - displayName, firstName, lastName, err := computeNameValues(contact) + // set the name and formatted name + displayName, firstName, lastName, err := computeNameValues(contact) + if err != nil { + return "", fmt.Errorf("failed to compute names: %s", err) + } + + card.SetValue(govcard.FieldFormattedName, displayName) + card.AddName(&govcard.Name{ + GivenName: firstName, + FamilyName: lastName, + }) + + // set the emails + if val, ok := contact["JSON Emails"].(string); ok { + decoder := json.NewDecoder(strings.NewReader(val)) + var emails []struct { + Label string `json:"label"` + Value string `json:"value"` + Preferred bool `json:"preferred"` + } + err := decoder.Decode(&emails) if err != nil { - return "", fmt.Errorf("failed to compute names: %s", err) + return "", fmt.Errorf("failed to parse JSON emails: %s", err) } - - card.SetValue(govcard.FieldFormattedName, displayName) - card.AddName(&govcard.Name{ - GivenName: firstName, - FamilyName: lastName, - }) - - // set the emails - if val, ok := contact["JSON Emails"].(string); ok { - decoder := json.NewDecoder(strings.NewReader(val)) - emails := []struct { - Label string `json:"label"` - Value string `json:"value"` - Preferred bool `json:"preferred"` - }{} - err := decoder.Decode(&emails) - if err != nil { - return "", fmt.Errorf("failed to parse JSON emails: %s", err) - } - for i, email := range emails { - preferred := "1" - if email.Preferred { - preferred = "2" - } - card.Add(fmt.Sprintf("item%d.%s", i+1, govcard.FieldEmail), &govcard.Field{ - Value: email.Value, - Params: map[string][]string{ - govcard.ParamPreferred: {preferred}, - }, - }) - card.Add(fmt.Sprintf("item%d.X-ABLABEL", i+1), &govcard.Field{ - Value: email.Label, - }) + for i, email := range emails { + preferred := "1" + if email.Preferred { + preferred = "2" } + card.Add(fmt.Sprintf("item%d.%s", i+1, govcard.FieldEmail), &govcard.Field{ + Value: email.Value, + Params: map[string][]string{ + govcard.ParamPreferred: {preferred}, + }, + }) + card.Add(fmt.Sprintf("item%d.X-ABLABEL", i+1), &govcard.Field{ + Value: email.Label, + }) } + } - // set the phone numbers - if val, ok := contact["JSON Phone Numbers"].(string); ok { - decoder := json.NewDecoder(strings.NewReader(val)) - numbers := []struct { - Type string `json:"type"` - Value string `json:"value"` - }{} - err := decoder.Decode(&numbers) - if err != nil { - return "", fmt.Errorf("failed to parse JSON phone numbers: %s", err) - } - for _, number := range numbers { - card.Add(govcard.FieldTelephone, &govcard.Field{ - Value: number.Value, - Params: map[string][]string{ - govcard.ParamType: {number.Type}, - }, - }) - } + // set the phone numbers + if val, ok := contact["JSON Phone Numbers"].(string); ok { + decoder := json.NewDecoder(strings.NewReader(val)) + var numbers []struct { + Type string `json:"type"` + Value string `json:"value"` } - - // set addresses - if val, ok := contact["JSON Addresses"].(string); ok { - decoder := json.NewDecoder(strings.NewReader(val)) - var addresses []govcard.Address - err := decoder.Decode(&addresses) - if err != nil { - return "", fmt.Errorf("failed to parse JSON addresses: %s", err) - } - for _, address := range addresses { - card.AddAddress(&address) - } + err := decoder.Decode(&numbers) + if err != nil { + return "", fmt.Errorf("failed to parse JSON phone numbers: %s", err) } - - // set note - if val, ok := contact["Note"].(string); ok { - card.SetValue(govcard.FieldNote, val) + for _, number := range numbers { + card.Add(govcard.FieldTelephone, &govcard.Field{ + Value: number.Value, + Params: map[string][]string{ + govcard.ParamType: {number.Type}, + }, + }) } + } - // set org from company value - if val, ok := contact["Company"].(string); ok { - card.SetValue(govcard.FieldOrganization, val) + // set addresses + if val, ok := contact["JSON Addresses"].(string); ok { + decoder := json.NewDecoder(strings.NewReader(val)) + var addresses []govcard.Address + err := decoder.Decode(&addresses) + if err != nil { + return "", fmt.Errorf("failed to parse JSON addresses: %s", err) } + for _, address := range addresses { + card.AddAddress(&address) + } + } - // set photo value - if val, ok := contact["Profile Image"].([]interface{}); ok { - if len(val) > 0 { - photo, ok := val[0].(map[string]interface{}) - if ok { - photoURL, photoURLOk := photo["url"].(string) - if photoURLOk { - resp, err := http.Get(photoURL) - if err != nil { - return "", fmt.Errorf("failed to get photo: %s", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("failed to get photo: %s", resp.Status) - } - - image, _, err := image.Decode(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to decode photo: %s", err) - } - dstImage := imaging.Fill(image, photoSize, photoSize, imaging.Center, imaging.Lanczos) - - buf := new(bytes.Buffer) - err = jpeg.Encode(buf, dstImage, nil) - if err != nil { - return "", fmt.Errorf("failed to encode photo to jpg: %s", err) - } - - card.Set(govcard.FieldPhoto, &govcard.Field{ - Value: base64.StdEncoding.EncodeToString(buf.Bytes()), - Params: map[string][]string{ - govcard.ParamType: {"JPEG"}, - "ENCODING": {"b"}, - }, - }) + // set note + var noteText string + if val, ok := contact["Note"].(string); ok { + noteText = val + } + noteText = fmt.Sprintf("%s\n%s", noteText, CRC32Hash(contact)) + card.SetValue(govcard.FieldNote, noteText) + + // set org from company value + if val, ok := contact["Company"].(string); ok { + card.SetValue(govcard.FieldOrganization, val) + } + + // set photo value + if val, ok := contact["Profile Image"].([]interface{}); ok { + if len(val) > 0 { + photo, ok := val[0].(map[string]interface{}) + if ok { + photoURL, photoURLOk := photo["url"].(string) + if photoURLOk { + buf, err := fetchPhoto(photoURL, photoSize) + if err != nil { + return "", err } + + card.Set(govcard.FieldPhoto, &govcard.Field{ + Value: base64.StdEncoding.EncodeToString(buf.Bytes()), + Params: map[string][]string{ + govcard.ParamType: {"JPEG"}, + "ENCODING": {"b"}, + }, + }) } } } + } - // optionally, set the ID - if id != "" { - card.SetValue("UID", id) - } + // optionally, set the ID + if id != "" { + card.SetValue("UID", id) + } - // allow setting of the version - if useV3 { - card.SetValue(govcard.FieldVersion, "3.0") - } else { - govcard.ToV4(card) - } + // allow setting of the version + if useV3 { + card.SetValue(govcard.FieldVersion, "3.0") + } else { + govcard.ToV4(card) + } - // write the card to output - err = enc.Encode(card) - if err != nil { - return "", fmt.Errorf("failed to encode vcard: %s", err) - } + // write the card to output + err = enc.Encode(card) + if err != nil { + return "", fmt.Errorf("failed to encode vcard: %s", err) + } + + rePhotoEncodingAttr := regexp.MustCompile(`ENCODING=\w;`) + return rePhotoEncodingAttr.ReplaceAllString(buf.String(), "ENCODING=B;"), nil +} + +func fetchPhoto(photoURL string, photoSize int) (*bytes.Buffer, error) { + resp, err := http.Get(photoURL) + if err != nil { + return nil, fmt.Errorf("failed to get photo: %s", err) } + defer resp.Body.Close() - out := strings.Replace( - strings.TrimSpace(buf.String()), - "TYPE=JPEG;ENCODING=b", - "ENCODING=b;TYPE=JPEG", - 1, - ) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get photo: %s", resp.Status) + } - return out, nil + img, err := imaging.Decode(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to decode photo: %s", err) + } + dstImage := imaging.Fill(img, photoSize, photoSize, imaging.Center, imaging.Lanczos) + + buf := new(bytes.Buffer) + err = jpeg.Encode(buf, dstImage, nil) + if err != nil { + return nil, fmt.Errorf("failed to encode photo to jpg: %s", err) + } + return buf, nil } func computeNameValues(contact map[string]interface{}) (string, string, string, error) {