feat: import system contacts
Co-authored-by: Paul Bohm <29411+enki@users.noreply.github.com> Co-authored-by: Octavio Froid <froid@bohm.com>
This commit is contained in:
parent
4b84b90a66
commit
403fda0fe7
@ -18,6 +18,7 @@
|
||||
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
|
||||
- Messages: add JSON export with `messages export --after` and `--before` filters.
|
||||
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
|
||||
- Contacts: add `contacts import-system` to import macOS Contacts display names as local metadata with alias-first precedence. (#33 — thanks @enki and @octaviofroid)
|
||||
- Auth: add `auth --qr-format text` to print the raw WhatsApp QR payload for external renderers. (#22 — thanks @teren-papercutlabs)
|
||||
- Auth: add `auth --phone` for WhatsApp's phone-number pairing flow on headless systems. (#148, #184 — thanks @giovanninibarbosa and @KillerSnails)
|
||||
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
|
||||
|
||||
@ -25,7 +25,7 @@ Full docs site: <https://wacli.sh>.
|
||||
- [Messages](docs/messages.md): `messages list/search/starred/show/context/export/edit/delete`.
|
||||
- [Send](docs/send.md): `send text/file/sticker/voice/react`, recipient resolution, replies.
|
||||
- [Media](docs/media.md): `media download`.
|
||||
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
|
||||
- [Contacts](docs/contacts.md): `contacts search/show/refresh/import-system`, aliases, tags.
|
||||
- [Chats](docs/chats.md): `chats list/show`, archive, pin, mute, mark read.
|
||||
- [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join.
|
||||
- [Store](docs/store.md): local store stats and cleanup commands.
|
||||
@ -47,7 +47,7 @@ Full docs site: <https://wacli.sh>.
|
||||
- **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters.
|
||||
- **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr.
|
||||
- **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB.
|
||||
- **Contacts/chats/groups/store/channels**: search/show contacts, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels.
|
||||
- **Contacts/chats/groups/store/channels**: search/show contacts, import macOS Contacts names, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels.
|
||||
- **Presence**: send typing/paused indicators.
|
||||
- **Profile**: set the authenticated account profile picture from JPEG or PNG input.
|
||||
- **Diagnostics + safety**: `doctor`, read-only mode, store locks with lock-owner reporting, lock waiting, owner-only database permissions, panic recovery, reconnect bounds, and bounded media queue backpressure.
|
||||
@ -209,6 +209,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
|
||||
- `wacli contacts search <query>`
|
||||
- `wacli contacts show --jid JID`
|
||||
- `wacli contacts refresh`
|
||||
- `wacli contacts import-system [--input FILE] [--dry-run] [--clear]`
|
||||
- `wacli contacts alias set|rm --jid JID [--alias NAME]`
|
||||
- `wacli contacts tags add|rm --jid JID --tag TAG`
|
||||
- `wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]`
|
||||
|
||||
@ -18,6 +18,7 @@ func newContactsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.AddCommand(newContactsSearchCmd(flags))
|
||||
cmd.AddCommand(newContactsShowCmd(flags))
|
||||
cmd.AddCommand(newContactsRefreshCmd(flags))
|
||||
cmd.AddCommand(newContactsImportSystemCmd(flags))
|
||||
cmd.AddCommand(newContactsAliasCmd(flags))
|
||||
cmd.AddCommand(newContactsTagsCmd(flags))
|
||||
return cmd
|
||||
@ -104,6 +105,9 @@ func newContactsShowCmd(flags *rootFlags) *cobra.Command {
|
||||
if c.Alias != "" {
|
||||
fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias)
|
||||
}
|
||||
if c.SystemName != "" {
|
||||
fmt.Fprintf(os.Stdout, "System Name: %s\n", c.SystemName)
|
||||
}
|
||||
if len(c.Tags) > 0 {
|
||||
fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", "))
|
||||
}
|
||||
|
||||
182
cmd/wacli/contacts_import_system.go
Normal file
182
cmd/wacli/contacts_import_system.go
Normal file
@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/syscontacts"
|
||||
)
|
||||
|
||||
type systemContactMatch struct {
|
||||
JID string `json:"jid"`
|
||||
Phone string `json:"phone"`
|
||||
CurrentName string `json:"current_name"`
|
||||
SystemName string `json:"system_name"`
|
||||
ExistingValue string `json:"existing_system_name,omitempty"`
|
||||
}
|
||||
|
||||
func newContactsImportSystemCmd(flags *rootFlags) *cobra.Command {
|
||||
var dryRun bool
|
||||
var clear bool
|
||||
var input string
|
||||
cmd := &cobra.Command{
|
||||
Use: "import-system",
|
||||
Short: "Import display names from macOS Contacts",
|
||||
Long: `Import display names from macOS Contacts and store them as local system names.
|
||||
|
||||
System names are local wacli metadata. They do not edit WhatsApp contacts or
|
||||
macOS Contacts. Display precedence is: alias, system name, WhatsApp names.
|
||||
|
||||
On macOS, the default source is the Contacts framework. Use --input to import
|
||||
from a JSON array or NDJSON file with fields first_name, last_name, full_name,
|
||||
and phones.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !dryRun {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, !dryRun, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if clear {
|
||||
return runContactsSystemClear(a.DB(), dryRun, flags.asJSON)
|
||||
}
|
||||
|
||||
systemContacts, err := readSystemContacts(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phoneToName := syscontacts.PhoneToName(systemContacts)
|
||||
localContacts, err := a.DB().ListContacts(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches, skippedNoPhone, skippedNoMatch, skippedSame := matchSystemContacts(localContacts, phoneToName)
|
||||
result := map[string]any{
|
||||
"matched": len(matches),
|
||||
"matches": matches,
|
||||
"skipped_no_phone": skippedNoPhone,
|
||||
"skipped_no_match": skippedNoMatch,
|
||||
"skipped_same": skippedSame,
|
||||
"dry_run": dryRun,
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, result)
|
||||
}
|
||||
writeSystemImportPreview(matches, skippedNoPhone, skippedNoMatch, skippedSame)
|
||||
return nil
|
||||
}
|
||||
|
||||
applied := 0
|
||||
for _, m := range matches {
|
||||
if err := a.DB().SetSystemName(m.JID, m.SystemName); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to set system name for %s: %v\n", m.JID, err)
|
||||
continue
|
||||
}
|
||||
applied++
|
||||
}
|
||||
result["applied"] = applied
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, result)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Applied %d system contact name(s).\n", applied)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be imported without writing")
|
||||
cmd.Flags().BoolVar(&clear, "clear", false, "clear all imported system names")
|
||||
cmd.Flags().StringVar(&input, "input", "", "read system contacts from JSON/NDJSON instead of macOS Contacts")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func readSystemContacts(ctx context.Context, input string) ([]syscontacts.Contact, error) {
|
||||
if input != "" {
|
||||
return syscontacts.ReadFile(input)
|
||||
}
|
||||
return syscontacts.ReadSystem(ctx)
|
||||
}
|
||||
|
||||
func runContactsSystemClear(db *store.DB, dryRun, asJSON bool) error {
|
||||
count, err := db.CountSystemNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRun {
|
||||
if asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"would_clear": count, "dry_run": true})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Would clear %d system contact name(s).\n", count)
|
||||
return nil
|
||||
}
|
||||
cleared, err := db.ClearAllSystemNames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"cleared": cleared})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Cleared %d system contact name(s).\n", cleared)
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchSystemContacts(local []store.Contact, phoneToName map[string]string) ([]systemContactMatch, int, int, int) {
|
||||
var matches []systemContactMatch
|
||||
var skippedNoPhone, skippedNoMatch, skippedSame int
|
||||
for _, c := range local {
|
||||
phone := syscontacts.NormalizePhone(c.Phone)
|
||||
if phone == "" {
|
||||
skippedNoPhone++
|
||||
continue
|
||||
}
|
||||
systemName, ok := phoneToName[phone]
|
||||
if !ok {
|
||||
skippedNoMatch++
|
||||
continue
|
||||
}
|
||||
if c.SystemName == systemName {
|
||||
skippedSame++
|
||||
continue
|
||||
}
|
||||
matches = append(matches, systemContactMatch{
|
||||
JID: c.JID,
|
||||
Phone: c.Phone,
|
||||
CurrentName: c.Name,
|
||||
SystemName: systemName,
|
||||
ExistingValue: c.SystemName,
|
||||
})
|
||||
}
|
||||
return matches, skippedNoPhone, skippedNoMatch, skippedSame
|
||||
}
|
||||
|
||||
func writeSystemImportPreview(matches []systemContactMatch, skippedNoPhone, skippedNoMatch, skippedSame int) {
|
||||
fmt.Fprintf(os.Stdout, "Would import %d system contact name(s).\n", len(matches))
|
||||
fmt.Fprintf(os.Stdout, "Skipped: %d no phone, %d no match, %d already current.\n", skippedNoPhone, skippedNoMatch, skippedSame)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
w := newTableWriter(os.Stdout)
|
||||
fmt.Fprintln(w, "PHONE\tCURRENT\tSYSTEM")
|
||||
for _, m := range matches {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n",
|
||||
tableCell(m.Phone, 16, false),
|
||||
tableCell(m.CurrentName, 24, false),
|
||||
tableCell(m.SystemName, 24, false),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
99
cmd/wacli/contacts_import_system_test.go
Normal file
99
cmd/wacli/contacts_import_system_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) {
|
||||
storeDir, input := seedSystemImportStore(t)
|
||||
|
||||
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
|
||||
cmd.SetArgs([]string{"--input", input, "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("contacts import-system dry-run: %v", err)
|
||||
}
|
||||
|
||||
db := openSystemImportStore(t, storeDir)
|
||||
defer db.Close()
|
||||
c, err := db.GetContact("14157347847@s.whatsapp.net")
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact: %v", err)
|
||||
}
|
||||
if c.SystemName != "" {
|
||||
t.Fatalf("dry-run wrote system name: %#v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsImportSystemFromInputWritesAndClears(t *testing.T) {
|
||||
storeDir, input := seedSystemImportStore(t)
|
||||
|
||||
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
|
||||
cmd.SetArgs([]string{"--input", input})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("contacts import-system: %v", err)
|
||||
}
|
||||
|
||||
db := openSystemImportStore(t, storeDir)
|
||||
c, err := db.GetContact("14157347847@s.whatsapp.net")
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact: %v", err)
|
||||
}
|
||||
if c.SystemName != "Alice Appleseed" || c.Name != "Alice Appleseed" {
|
||||
t.Fatalf("contact = %#v", c)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
clearCmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
|
||||
clearCmd.SetArgs([]string{"--clear"})
|
||||
if err := clearCmd.Execute(); err != nil {
|
||||
t.Fatalf("contacts import-system --clear: %v", err)
|
||||
}
|
||||
db = openSystemImportStore(t, storeDir)
|
||||
defer db.Close()
|
||||
c, err = db.GetContact("14157347847@s.whatsapp.net")
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact after clear: %v", err)
|
||||
}
|
||||
if c.SystemName != "" {
|
||||
t.Fatalf("clear left system name: %#v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func seedSystemImportStore(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
storeDir := t.TempDir()
|
||||
db := openSystemImportStore(t, storeDir)
|
||||
if err := db.UpsertContact("14157347847@s.whatsapp.net", "14157347847", "WhatsApp Alice", "", "", ""); err != nil {
|
||||
t.Fatalf("UpsertContact: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
input := filepath.Join(storeDir, "contacts.json")
|
||||
raw, err := json.Marshal([]map[string]any{
|
||||
{"full_name": "Alice Appleseed", "phones": []string{"+1 (415) 734-7847"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(input, raw, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return storeDir, input
|
||||
}
|
||||
|
||||
func openSystemImportStore(t *testing.T, storeDir string) *store.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
# contacts
|
||||
|
||||
Read when: finding synced contacts or managing local contact metadata.
|
||||
Read when: finding synced contacts, importing macOS Contacts names, or managing local contact metadata.
|
||||
|
||||
`wacli contacts` works with contact metadata stored locally. Aliases and tags are local to `wacli`; they do not edit WhatsApp contacts on the phone.
|
||||
|
||||
@ -10,6 +10,7 @@ Read when: finding synced contacts or managing local contact metadata.
|
||||
wacli contacts search <query> [--limit N]
|
||||
wacli contacts show --jid JID
|
||||
wacli contacts refresh
|
||||
wacli contacts import-system [--input FILE] [--dry-run] [--clear]
|
||||
wacli contacts alias set --jid JID --alias NAME
|
||||
wacli contacts alias rm --jid JID
|
||||
wacli contacts tags add --jid JID --tag TAG
|
||||
@ -20,7 +21,11 @@ wacli contacts tags rm --jid JID --tag TAG
|
||||
|
||||
- `search` matches alias, full name, push name, first name, business name, phone, and JID.
|
||||
- `refresh` imports contacts from the whatsmeow session store into `wacli.db`.
|
||||
- Local aliases are preferred in contact search and display.
|
||||
- `import-system` imports display names from macOS Contacts by matching phone numbers against already-synced wacli contacts. Run `contacts refresh` first.
|
||||
- `import-system --input FILE` reads a JSON array or newline-delimited JSON contacts file with `full_name` and `phones` fields instead of opening macOS Contacts.
|
||||
- Imported system names are local wacli metadata. They do not edit WhatsApp contacts or macOS Contacts.
|
||||
- Display precedence is local alias, imported system name, then WhatsApp names.
|
||||
- Use `import-system --dry-run` before writing. Use `import-system --clear` to remove imported system names.
|
||||
- Tags are local grouping metadata for scripts and future workflows.
|
||||
|
||||
## Examples
|
||||
@ -29,6 +34,7 @@ wacli contacts tags rm --jid JID --tag TAG
|
||||
wacli contacts search Alice
|
||||
wacli contacts show --jid 1234567890@s.whatsapp.net
|
||||
wacli contacts refresh
|
||||
wacli contacts import-system --dry-run
|
||||
wacli contacts alias set --jid 1234567890@s.whatsapp.net --alias mom
|
||||
wacli contacts tags add --jid 1234567890@s.whatsapp.net --tag family
|
||||
```
|
||||
|
||||
@ -26,7 +26,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
|
||||
- [messages](messages.md) - list, search, show, and contextualize stored messages.
|
||||
- [send](send.md) - send text, files, stickers, replies, and reactions.
|
||||
- [media](media.md) - download media attached to stored messages.
|
||||
- [contacts](contacts.md) - search contacts and manage local aliases/tags.
|
||||
- [contacts](contacts.md) - search contacts, import macOS Contacts names, and manage local aliases/tags.
|
||||
- [chats](chats.md) - list, show, filter, and manage known chat state.
|
||||
- [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants.
|
||||
- [store](store.md) - inspect local store stats and prune stale local rows.
|
||||
|
||||
@ -16,15 +16,23 @@ func (d *DB) SearchContacts(query string, limit int) ([]Contact, error) {
|
||||
SELECT c.jid,
|
||||
COALESCE(c.phone,''),
|
||||
COALESCE(NULLIF(a.alias,''), ''),
|
||||
COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
|
||||
COALESCE(NULLIF(c.system_name,''), ''),
|
||||
COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
|
||||
c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN contact_aliases a ON a.jid = c.jid
|
||||
WHERE LOWER(COALESCE(a.alias,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.full_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.push_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.first_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.business_name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(c.phone,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(c.jid) LIKE LOWER(?) ESCAPE '\'
|
||||
ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid)
|
||||
WHERE LOWER(COALESCE(a.alias,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.system_name,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.full_name,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.push_name,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.first_name,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.business_name,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(COALESCE(c.phone,'')) LIKE LOWER(?) ESCAPE '\'
|
||||
OR LOWER(c.jid) LIKE LOWER(?) ESCAPE '\'
|
||||
ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid)
|
||||
LIMIT ?`
|
||||
needle := likeContains(query)
|
||||
rows, err := d.sql.Query(q, needle, needle, needle, needle, needle, needle, needle, limit)
|
||||
rows, err := d.sql.Query(q, needle, needle, needle, needle, needle, needle, needle, needle, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -34,7 +42,40 @@ func (d *DB) SearchContacts(query string, limit int) ([]Contact, error) {
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var updated int64
|
||||
if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil {
|
||||
if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UpdatedAt = fromUnix(updated)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) ListContacts(limit int) ([]Contact, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100000
|
||||
}
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT c.jid,
|
||||
COALESCE(c.phone,''),
|
||||
COALESCE(NULLIF(a.alias,''), ''),
|
||||
COALESCE(NULLIF(c.system_name,''), ''),
|
||||
COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
|
||||
c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN contact_aliases a ON a.jid = c.jid
|
||||
ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), c.jid)
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Contact
|
||||
for rows.Next() {
|
||||
var c Contact
|
||||
var updated int64
|
||||
if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UpdatedAt = fromUnix(updated)
|
||||
@ -48,7 +89,8 @@ func (d *DB) GetContact(jid string) (Contact, error) {
|
||||
SELECT c.jid,
|
||||
COALESCE(c.phone,''),
|
||||
COALESCE(NULLIF(a.alias,''), ''),
|
||||
COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
|
||||
COALESCE(NULLIF(c.system_name,''), ''),
|
||||
COALESCE(NULLIF(a.alias,''), NULLIF(c.system_name,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
|
||||
c.updated_at
|
||||
FROM contacts c
|
||||
LEFT JOIN contact_aliases a ON a.jid = c.jid
|
||||
@ -56,7 +98,7 @@ func (d *DB) GetContact(jid string) (Contact, error) {
|
||||
`, jid)
|
||||
var c Contact
|
||||
var updated int64
|
||||
if err := row.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil {
|
||||
if err := row.Scan(&c.JID, &c.Phone, &c.Alias, &c.SystemName, &c.Name, &updated); err != nil {
|
||||
return Contact{}, err
|
||||
}
|
||||
c.UpdatedAt = fromUnix(updated)
|
||||
@ -65,6 +107,44 @@ func (d *DB) GetContact(jid string) (Contact, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetSystemName(jid, systemName string) error {
|
||||
jid = strings.TrimSpace(jid)
|
||||
systemName = strings.TrimSpace(systemName)
|
||||
if jid == "" {
|
||||
return fmt.Errorf("jid is required")
|
||||
}
|
||||
if systemName == "" {
|
||||
return fmt.Errorf("system name is required")
|
||||
}
|
||||
now := nowUTC().Unix()
|
||||
res, err := d.sql.Exec(`UPDATE contacts SET system_name = ?, updated_at = ? WHERE jid = ?`, systemName, now, jid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return fmt.Errorf("contact not found: %s", jid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) ClearAllSystemNames() (int64, error) {
|
||||
now := nowUTC().Unix()
|
||||
res, err := d.sql.Exec(`UPDATE contacts SET system_name = NULL, updated_at = ? WHERE system_name IS NOT NULL AND system_name != ''`, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *DB) CountSystemNames() (int64, error) {
|
||||
row := d.sql.QueryRow(`SELECT COUNT(1) FROM contacts WHERE system_name IS NOT NULL AND system_name != ''`)
|
||||
var n int64
|
||||
if err := row.Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListTags(jid string) ([]string, error) {
|
||||
rows, err := d.sql.Query(`SELECT tag FROM contact_tags WHERE jid = ? ORDER BY tag`, jid)
|
||||
if err != nil {
|
||||
|
||||
@ -67,3 +67,57 @@ func TestContactsAliasTagsAndSearch(t *testing.T) {
|
||||
t.Fatalf("expected remaining tag friends, got %v", c.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactSystemNamePrecedenceAndSearch(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
jid := "111@s.whatsapp.net"
|
||||
if err := db.UpsertContact(jid, "111", "Push", "Full Name", "First", "Biz"); err != nil {
|
||||
t.Fatalf("UpsertContact: %v", err)
|
||||
}
|
||||
if err := db.SetSystemName(jid, "System Alice"); err != nil {
|
||||
t.Fatalf("SetSystemName: %v", err)
|
||||
}
|
||||
|
||||
c, err := db.GetContact(jid)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact: %v", err)
|
||||
}
|
||||
if c.Name != "System Alice" || c.SystemName != "System Alice" {
|
||||
t.Fatalf("contact = %#v", c)
|
||||
}
|
||||
|
||||
found, err := db.SearchContacts("System", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchContacts: %v", err)
|
||||
}
|
||||
if len(found) != 1 || found[0].JID != jid {
|
||||
t.Fatalf("expected system-name match, got %#v", found)
|
||||
}
|
||||
|
||||
if err := db.SetAlias(jid, "Alias Alice"); err != nil {
|
||||
t.Fatalf("SetAlias: %v", err)
|
||||
}
|
||||
c, err = db.GetContact(jid)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContact after alias: %v", err)
|
||||
}
|
||||
if c.Name != "Alias Alice" {
|
||||
t.Fatalf("alias should win over system name, got %#v", c)
|
||||
}
|
||||
|
||||
count, err := db.CountSystemNames()
|
||||
if err != nil {
|
||||
t.Fatalf("CountSystemNames: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("system name count = %d, want 1", count)
|
||||
}
|
||||
cleared, err := db.ClearAllSystemNames()
|
||||
if err != nil {
|
||||
t.Fatalf("ClearAllSystemNames: %v", err)
|
||||
}
|
||||
if cleared != 1 {
|
||||
t.Fatalf("cleared = %d, want 1", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ var schemaMigrations = []migration{
|
||||
{version: 9, name: "messages deleted_for_me column", up: migrateMessagesDeletedForMeColumn},
|
||||
{version: 10, name: "chat state columns", up: migrateChatStateColumns},
|
||||
{version: 11, name: "group hierarchy columns", up: migrateGroupHierarchyColumns},
|
||||
{version: 12, name: "contacts system_name column", up: migrateContactsSystemNameColumn},
|
||||
}
|
||||
|
||||
func (d *DB) ensureSchema() error {
|
||||
@ -237,6 +238,27 @@ func migrateGroupHierarchyColumns(d *DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateContactsSystemNameColumn(d *DB) error {
|
||||
hasContacts, err := d.tableExists("contacts")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasContacts {
|
||||
return nil
|
||||
}
|
||||
hasSystemName, err := d.tableHasColumn("contacts", "system_name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasSystemName {
|
||||
return nil
|
||||
}
|
||||
if _, err := d.sql.Exec(`ALTER TABLE contacts ADD COLUMN system_name TEXT`); err != nil {
|
||||
return fmt.Errorf("add contacts.system_name column: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateMessagesFTS(d *DB) error {
|
||||
ftsExists, err := d.tableExists("messages_fts")
|
||||
if err != nil {
|
||||
|
||||
@ -21,6 +21,7 @@ const coreSchemaSQL = `
|
||||
full_name TEXT,
|
||||
first_name TEXT,
|
||||
business_name TEXT,
|
||||
system_name TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@ -63,6 +63,14 @@ func TestOpenCreatesExpectedSchema(t *testing.T) {
|
||||
if !indexExists(t, db.sql, "idx_groups_linked_parent_jid") {
|
||||
t.Fatalf("expected linked-parent group index to exist")
|
||||
}
|
||||
|
||||
contactCols, err := tableColumns(db.sql, "contacts")
|
||||
if err != nil {
|
||||
t.Fatalf("contacts tableColumns: %v", err)
|
||||
}
|
||||
if !contactCols["system_name"] {
|
||||
t.Fatalf("expected contacts system_name column to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesGroupHierarchyColumns(t *testing.T) {
|
||||
@ -125,6 +133,62 @@ func TestOpenMigratesGroupHierarchyColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesContactsSystemNameColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "wacli.db")
|
||||
|
||||
raw, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if _, err := raw.Exec(`
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE contacts (
|
||||
jid TEXT PRIMARY KEY,
|
||||
phone TEXT,
|
||||
push_name TEXT,
|
||||
full_name TEXT,
|
||||
first_name TEXT,
|
||||
business_name TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO contacts(jid, phone, updated_at) VALUES('111@s.whatsapp.net', '111', 1);
|
||||
`); err != nil {
|
||||
_ = raw.Close()
|
||||
t.Fatalf("create old contacts schema: %v", err)
|
||||
}
|
||||
for _, m := range schemaMigrations {
|
||||
if m.version >= 12 {
|
||||
continue
|
||||
}
|
||||
if _, err := raw.Exec(`INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, 1)`, m.version, m.name); err != nil {
|
||||
_ = raw.Close()
|
||||
t.Fatalf("mark migration %d: %v", m.version, err)
|
||||
}
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatalf("raw close: %v", err)
|
||||
}
|
||||
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open migrated DB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
contactCols, err := tableColumns(db.sql, "contacts")
|
||||
if err != nil {
|
||||
t.Fatalf("contacts tableColumns: %v", err)
|
||||
}
|
||||
if !contactCols["system_name"] {
|
||||
t.Fatalf("expected migrated contacts system_name column")
|
||||
}
|
||||
}
|
||||
|
||||
func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
|
||||
@ -95,12 +95,13 @@ type MessageInfo struct {
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
JID string
|
||||
Phone string
|
||||
Name string
|
||||
Alias string
|
||||
Tags []string
|
||||
UpdatedAt time.Time
|
||||
JID string `json:"jid"`
|
||||
Phone string `json:"phone"`
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias"`
|
||||
SystemName string `json:"system_name"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func unix(t time.Time) int64 {
|
||||
|
||||
79
internal/syscontacts/contacts_export.swift
Normal file
79
internal/syscontacts/contacts_export.swift
Normal file
@ -0,0 +1,79 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
|
||||
struct WacliContact: Codable {
|
||||
let first_name: String
|
||||
let last_name: String
|
||||
let full_name: String
|
||||
let phones: [String]
|
||||
}
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
break
|
||||
case .notDetermined:
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var granted = false
|
||||
var requestError: Error?
|
||||
store.requestAccess(for: .contacts) { ok, err in
|
||||
granted = ok
|
||||
requestError = err
|
||||
sem.signal()
|
||||
}
|
||||
_ = sem.wait(timeout: .now() + 60)
|
||||
if !granted {
|
||||
if let requestError {
|
||||
fail("Contacts access denied: \(requestError.localizedDescription)")
|
||||
}
|
||||
fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
||||
@unknown default:
|
||||
fail("Contacts access is unavailable for this process.")
|
||||
}
|
||||
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
]
|
||||
|
||||
let request = CNContactFetchRequest(keysToFetch: keys)
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: request) { contact, _ in
|
||||
let phones = contact.phoneNumbers
|
||||
.map { $0.value.stringValue }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !phones.isEmpty else { return }
|
||||
|
||||
var fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||
if fullName.isEmpty {
|
||||
fullName = contact.organizationName
|
||||
}
|
||||
guard !fullName.isEmpty else { return }
|
||||
|
||||
let row = WacliContact(
|
||||
first_name: contact.givenName,
|
||||
last_name: contact.familyName,
|
||||
full_name: fullName,
|
||||
phones: phones
|
||||
)
|
||||
if let data = try? encoder.encode(row),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fail("Failed to enumerate Contacts: \(error.localizedDescription)")
|
||||
}
|
||||
46
internal/syscontacts/darwin.go
Normal file
46
internal/syscontacts/darwin.go
Normal file
@ -0,0 +1,46 @@
|
||||
//go:build darwin
|
||||
|
||||
package syscontacts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//go:embed contacts_export.swift
|
||||
var contactsExportSwift string
|
||||
|
||||
func ReadSystem(ctx context.Context) ([]Contact, error) {
|
||||
dir, err := os.MkdirTemp("", "wacli-contacts-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
script := filepath.Join(dir, "contacts-export.swift")
|
||||
if err := os.WriteFile(script, []byte(contactsExportSwift), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := swiftCommand(ctx, script)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("read macOS Contacts: %s", string(ee.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("run swift Contacts helper: %w", err)
|
||||
}
|
||||
return Decode(bytes.NewReader(out))
|
||||
}
|
||||
|
||||
func swiftCommand(ctx context.Context, script string) *exec.Cmd {
|
||||
if path, err := exec.LookPath("swift"); err == nil {
|
||||
return exec.CommandContext(ctx, path, script)
|
||||
}
|
||||
return exec.CommandContext(ctx, "xcrun", "swift", script)
|
||||
}
|
||||
9
internal/syscontacts/other.go
Normal file
9
internal/syscontacts/other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !darwin
|
||||
|
||||
package syscontacts
|
||||
|
||||
import "context"
|
||||
|
||||
func ReadSystem(ctx context.Context) ([]Contact, error) {
|
||||
return nil, UnsupportedError()
|
||||
}
|
||||
105
internal/syscontacts/syscontacts.go
Normal file
105
internal/syscontacts/syscontacts.go
Normal file
@ -0,0 +1,105 @@
|
||||
package syscontacts
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
FullName string `json:"full_name"`
|
||||
Phones []string `json:"phones"`
|
||||
}
|
||||
|
||||
func (c Contact) Name() string {
|
||||
if strings.TrimSpace(c.FullName) != "" {
|
||||
return strings.TrimSpace(c.FullName)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{c.FirstName, c.LastName}, " "))
|
||||
}
|
||||
|
||||
func ReadFile(path string) ([]Contact, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return Decode(f)
|
||||
}
|
||||
|
||||
func Decode(r io.Reader) ([]Contact, error) {
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var contacts []Contact
|
||||
if err := json.Unmarshal([]byte(trimmed), &contacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
|
||||
var contacts []Contact
|
||||
scanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var c Contact
|
||||
if err := json.Unmarshal([]byte(line), &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, scanner.Err()
|
||||
}
|
||||
|
||||
func PhoneToName(contacts []Contact) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, c := range contacts {
|
||||
name := c.Name()
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
for _, phone := range c.Phones {
|
||||
normalized := NormalizePhone(phone)
|
||||
if len(normalized) < 7 {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[normalized]; !exists {
|
||||
out[normalized] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range phone {
|
||||
if unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.HasPrefix(out, "00") {
|
||||
out = strings.TrimPrefix(out, "00")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UnsupportedError() error {
|
||||
return fmt.Errorf("system contacts import is only supported on macOS; pass --input with JSON/NDJSON contacts to import from a file")
|
||||
}
|
||||
48
internal/syscontacts/syscontacts_test.go
Normal file
48
internal/syscontacts/syscontacts_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package syscontacts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePhone(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"+1 (415) 734-7847", "14157347847"},
|
||||
{"0043 664 104 2436", "436641042436"},
|
||||
{"14157347847", "14157347847"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := NormalizePhone(tt.in); got != tt.want {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSupportsJSONArrayAndNDJSON(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`[{"full_name":"Alice","phones":["+1 (415) 734-7847"]}]`,
|
||||
"{\"full_name\":\"Alice\",\"phones\":[\"+1 (415) 734-7847\"]}\n",
|
||||
} {
|
||||
contacts, err := Decode(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", input, err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name() != "Alice" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneToNameKeepsFirstNameForDuplicateNumber(t *testing.T) {
|
||||
got := PhoneToName([]Contact{
|
||||
{FullName: "Alice", Phones: []string{"+1 (415) 734-7847"}},
|
||||
{FullName: "Other", Phones: []string{"14157347847"}},
|
||||
})
|
||||
if got["14157347847"] != "Alice" {
|
||||
t.Fatalf("phone map = %#v", got)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user