feat: bootstrap clawdex cli

This commit is contained in:
Peter Steinberger 2026-05-08 12:24:58 +01:00
parent 82e9ed8237
commit fc837601e1
No known key found for this signature in database
44 changed files with 5468 additions and 0 deletions

187
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,187 @@
name: ci
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Lint
uses: golangci/golangci-lint-action@v9.2.0
with:
version: v2.12.1
- name: Install analyzers
run: |
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
go install mvdan.cc/gofumpt@v0.9.2
go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1
- name: Vet
run: go vet ./...
- name: Staticcheck
run: '"$(go env GOPATH)/bin/staticcheck" ./...'
- name: Gofumpt
run: |
changed="$("$(go env GOPATH)/bin/gofumpt" -l .)"
if [ -n "$changed" ]; then
printf 'gofumpt wants changes in:\n%s\n' "$changed"
exit 1
fi
- name: Gosec
run: |
"$(go env GOPATH)/bin/gosec" -exclude=G101,G115,G202,G301,G304 ./...
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Test with coverage
run: go test -count=1 ./... -coverprofile=coverage.out
- name: Test with race detector
run: go test -count=1 -race ./...
- name: Enforce coverage floor
run: |
total="$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%$/, "", $3); print $3 }')"
awk -v total="$total" 'BEGIN {
if (total == "") {
print "missing coverage total"
exit 1
}
if (total + 0 < 90.0) {
printf("coverage %.1f%% is below 90%%\n", total + 0)
exit 1
}
printf("coverage %.1f%%\n", total + 0)
}'
- name: Build
run: go build -o bin/clawdex ./cmd/clawdex
- name: Smoke test CLI control surface
run: |
set -euo pipefail
test -n "$(./bin/clawdex --version)"
output="$(./bin/clawdex --help)"
printf '%s\n' "$output"
printf '%s' "$output" | grep -q "person"
printf '%s' "$output" | grep -q "import"
tmp="$(mktemp -d)"
cfg="$tmp/config.toml"
repo="$tmp/contacts"
./bin/clawdex --config "$cfg" init "$repo" --remote ""
./bin/clawdex --config "$cfg" --plain person add "CI Person" --email ci@example.com
./bin/clawdex --config "$cfg" --json person show ci@example.com | grep -q '"name": "CI Person"'
./bin/clawdex --config "$cfg" note add ci@example.com --kind note --source manual --text "release smoke"
./bin/clawdex --config "$cfg" --plain search release | grep -q "CI Person"
./bin/clawdex --config "$cfg" doctor --repair --dry-run
deps:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Verify module cache
run: go mod verify
- name: Check go.mod tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0
- name: Run govulncheck
run: '"$(go env GOPATH)/bin/govulncheck" ./...'
release-check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Snapshot release build
uses: goreleaser/goreleaser-action@v7.2.1
with:
distribution: goreleaser
version: "~> v2"
args: release --snapshot --clean --skip=publish
secrets:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Install gitleaks
run: go install github.com/zricethezav/gitleaks/v8@v8.30.1
- name: Scan git history
run: |
"$(go env GOPATH)/bin/gitleaks" git --no-banner --redact
- name: Scan working tree
run: |
"$(go env GOPATH)/bin/gitleaks" dir . --no-banner --redact

106
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,106 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to (re)release (e.g. v0.1.0)"
required: true
type: string
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Stash GoReleaser config
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
- name: GoReleaser
uses: goreleaser/goreleaser-action@v7.2.1
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Resolve release tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="clawdex-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update clawdex for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=clawdex \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/clawdex \
-f description="Local-first personal contact index CLI backed by Markdown and Git" \
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/bin/
coverage.out
/dist/
/clawdex

46
.golangci.yml Normal file
View File

@ -0,0 +1,46 @@
version: "2"
linters:
enable:
- asasalint
- bidichk
- bodyclose
- canonicalheader
- copyloopvar
- dupword
- durationcheck
- errcheck
- errchkjson
- errorlint
- exptostd
- gocheckcompilerdirectives
- gocritic
- gomoddirectives
- govet
- intrange
- ineffassign
- makezero
- misspell
- modernize
- nilerr
- nilnesserr
- noctx
- nolintlint
- nosprintfhostport
- perfsprint
- predeclared
- rowserrcheck
- sloglint
- sqlclosecheck
- staticcheck
- testifylint
- unconvert
- unused
- usetesting
- usestdlibvars
- wastedassign
formatters:
enable:
- gofumpt
- goimports

36
.goreleaser.yaml Normal file
View File

@ -0,0 +1,36 @@
version: 2
project_name: clawdex
changelog:
disable: true
builds:
- id: clawdex
main: ./cmd/clawdex
binary: clawdex
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/openclaw/clawdex/internal/cli.Version={{ .Version }}
targets:
- darwin_amd64
- darwin_arm64
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
archives:
- ids:
- clawdex
formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
checksum:
name_template: checksums.txt

8
CHANGELOG.md Normal file
View File

@ -0,0 +1,8 @@
# Changelog
## Unreleased
- Initial `clawdex` CLI with markdown-backed people, timestamped notes, search, timeline, Git helpers, vCard export, and repair for damaged frontmatter.
- Added Apple Contacts import on macOS, Google Contacts import through `gog`, Discord DM backfill through Discrawl, and X/Twitter DM backfill through Birdclaw.
- Added CI with lint, tests, 90% coverage enforcement, race tests, dependency checks, secret scanning, and GoReleaser snapshot validation.
- Added GoReleaser config and release workflow that publishes cross-platform binaries and dispatches the Homebrew tap formula updater.

128
README.md Normal file
View File

@ -0,0 +1,128 @@
# clawdex
`clawdex` is a local-first personal contact index CLI. The app lives in this
repo; your contacts live in a separate private Git-backed markdown repo.
The default backup remote is:
```bash
https://github.com/steipete/backup-clawdex.git
```
## Setup
Install from Homebrew after the first tagged release:
```bash
brew install steipete/tap/clawdex
```
Or build locally:
```bash
go install github.com/openclaw/clawdex/cmd/clawdex@latest
```
```bash
clawdex init ~/.clawdex/contacts
clawdex config set repo_path ~/.clawdex/contacts
clawdex config set git.remote https://github.com/steipete/backup-clawdex.git
```
`init` creates a data repo:
```text
clawdex.toml
people/
index/
.clawdex/repairs/
```
Config is stored at `~/.clawdex/config.toml` by default. `--repo DIR` or
`CLAWDEX_REPO=DIR` overrides the configured contacts repo for one run.
## Examples
```bash
clawdex person add "Sally O'Malley" --email sally@example.com --tag friend
clawdex note add sally --kind dm --source whatsapp --text "Follow up about dinner"
clawdex person list
clawdex person show sally
clawdex timeline sally
clawdex search dinner
clawdex export vcard --all -o contacts.vcf
clawdex git status
clawdex git commit -m "sync: update contacts"
clawdex git push
```
## Imports And Sync Safety
Apple and Google imports write only to the local markdown data repo.
```bash
clawdex import apple --dry-run
clawdex import apple
clawdex import google --account steipete@gmail.com --dry-run
clawdex import birdclaw --min-messages 4 --dry-run
clawdex import discrawl --min-messages 4 --dry-run
```
Apple direct import uses macOS `Contacts.framework`. Linux builds still support
markdown, notes, search, Git, Google via `gog`, and vCard export.
Birdclaw and Discrawl DM imports read local archives only. They import DM
conversations with more than `--min-messages` messages, add source-specific
tags, and store stable pointers under `accounts.x` or `accounts.discord`.
`sync apple` and `sync google` are preview-only placeholders for now. Remote
address-book writes need a conflict report before they become active. Notes stay
local-only and are never written to Apple or Google.
## Markdown Repair
People and note files use YAML frontmatter plus a Markdown body. `clawdex`
parses strictly first, then does best-effort repair when frontmatter is damaged:
- salvage known scalar keys such as `id`, `name`, `created_at`, and note fields
- infer missing IDs and timestamps
- preserve the Markdown body
- copy the original file under `.clawdex/repairs/`
- append damaged metadata to the body under `Recovered metadata`
Preview repairs:
```bash
clawdex doctor --repair --dry-run
```
Apply repairs:
```bash
clawdex doctor --repair
```
## Storage
```text
people/
sally-o-malley/
person.md
notes/
2026-05-08T09-15-00Z-whatsapp.md
attachments/
index/
emails.json
phones.json
handles.json
```
The `index/*.json` files are derived and rebuildable. Markdown is canonical.
## Releases
Tagged releases are built by GoReleaser for macOS, Linux, and Windows. The
release workflow also dispatches `steipete/homebrew-tap` to update
`Formula/clawdex.rb` after the GitHub release assets are published.
Release checklist: [`docs/RELEASING.md`](docs/RELEASING.md).

21
cmd/clawdex/main.go Normal file
View File

@ -0,0 +1,21 @@
package main
import (
"fmt"
"io"
"os"
"github.com/openclaw/clawdex/internal/cli"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if err := cli.Execute(args, stdout, stderr); err != nil {
_, _ = fmt.Fprintln(stderr, err)
return cli.ExitCode(err)
}
return 0
}

21
cmd/clawdex/main_test.go Normal file
View File

@ -0,0 +1,21 @@
package main
import (
"bytes"
"path/filepath"
"strings"
"testing"
)
func TestRun(t *testing.T) {
var out, errOut bytes.Buffer
cfg := filepath.Join(t.TempDir(), "config.toml")
code := run([]string{"--config", cfg, "config"}, &out, &errOut)
if code != 0 || !strings.Contains(out.String(), "backup-clawdex") {
t.Fatalf("code=%d out=%s err=%s", code, out.String(), errOut.String())
}
code = run([]string{"--bogus"}, &out, &errOut)
if code != 2 {
t.Fatalf("code=%d", code)
}
}

94
docs/RELEASING.md Normal file
View File

@ -0,0 +1,94 @@
---
summary: "Release checklist for clawdex (GitHub release binaries via GoReleaser + Homebrew tap update)"
---
# Releasing `clawdex`
Always do all steps below. No partial releases.
Assumptions:
- Repo: `openclaw/clawdex`
- Binary: `clawdex`
- GoReleaser config: `.goreleaser.yaml`
- Homebrew tap repo: `~/Projects/homebrew-tap`
- Tap workflow: `steipete/homebrew-tap/.github/workflows/update-formula.yml`
## 0) Prereqs
- Clean working tree on `main`
- Go toolchain from `go.mod`
- GitHub CLI authenticated
- CI green on `main`
- `HOMEBREW_TAP_TOKEN` set in `openclaw/clawdex` Actions secrets
## 1) Verify build + tests
```sh
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.1 run
go test -count=1 ./... -coverprofile=coverage.out
go tool cover -func=coverage.out | tail -n 1
go test -count=1 -race ./...
go build -o /tmp/clawdex ./cmd/clawdex
goreleaser release --snapshot --clean --skip=publish
gh run list -L 5 --branch main
```
Coverage floor: `90%+`
## 2) Update changelog
Add a new section in `CHANGELOG.md`.
Example:
- `## 0.2.0 - 2026-05-08`
## 3) Commit, tag, push
```sh
git checkout main
git pull --ff-only origin main
git commit -am "release: vX.Y.Z"
git tag -a vX.Y.Z -m "Release X.Y.Z"
git push origin main --tags
```
## 4) Verify GitHub release assets
The tag push triggers `.github/workflows/release.yml`.
```sh
gh run list -L 5 --workflow release.yml
gh release view vX.Y.Z
```
Confirm assets exist for:
- `darwin_amd64`
- `darwin_arm64`
- `linux_amd64`
- `linux_arm64`
- `windows_amd64`
- `windows_arm64`
## 5) Verify Homebrew tap update
The release workflow dispatches the tap updater after GoReleaser succeeds. The
tap updater rewrites `Formula/clawdex.rb` with the release archive checksums.
```sh
gh run list --repo steipete/homebrew-tap --workflow update-formula.yml -L 5
brew update
brew reinstall steipete/tap/clawdex
clawdex --version
brew test steipete/tap/clawdex
```
## Notes
- Build-time version stamping comes from `-X github.com/openclaw/clawdex/internal/cli.Version={{ .Version }}`
- If release workflow needs a rerun:
```sh
gh workflow run release.yml -f tag=vX.Y.Z
```

12
go.mod Normal file
View File

@ -0,0 +1,12 @@
module github.com/openclaw/clawdex
go 1.26.2
require (
github.com/alecthomas/kong v1.15.0
github.com/google/uuid v1.6.0
github.com/openclaw/crawlkit v0.5.0
gopkg.in/yaml.v3 v3.0.1
)
require github.com/pelletier/go-toml/v2 v2.3.1 // indirect

18
go.sum Normal file
View File

@ -0,0 +1,18 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/openclaw/crawlkit v0.5.0 h1:sVqIbQ5v6LiOf+NXcVj93UhfoaJqMbBlrd1lU6uhO9M=
github.com/openclaw/crawlkit v0.5.0/go.mod h1:/AI8o/DeRqXPZJPHq/9mGUjNzLPskm/wTjikRPxEdHY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

94
internal/apple/apple.go Normal file
View File

@ -0,0 +1,94 @@
package apple
import (
"bufio"
"encoding/json"
"io"
"os"
"strings"
"github.com/openclaw/clawdex/internal/model"
)
type Contact struct {
Identifier string `json:"identifier"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
FullName string `json:"full_name"`
Emails []string `json:"emails"`
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 (c Contact) SourceContact() model.SourceContact {
out := model.SourceContact{Source: "apple", ExternalID: c.Identifier, Name: c.Name()}
for i, email := range c.Emails {
if strings.TrimSpace(email) != "" {
out.Emails = append(out.Emails, model.ContactValue{Value: email, Label: "other", Source: "apple", Primary: i == 0})
}
}
for i, phone := range c.Phones {
if strings.TrimSpace(phone) != "" {
out.Phones = append(out.Phones, model.ContactValue{Value: phone, Label: "other", Source: "apple", Primary: i == 0})
}
}
return out
}
func ReadFile(path string) ([]Contact, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = 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 ToSourceContacts(contacts []Contact) []model.SourceContact {
out := make([]model.SourceContact, 0, len(contacts))
for _, contact := range contacts {
if strings.TrimSpace(contact.Name()) == "" {
continue
}
out = append(out, contact.SourceContact())
}
return out
}

View File

@ -0,0 +1,55 @@
package apple
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDecodeJSONArrayAndNDJSON(t *testing.T) {
for _, input := range []string{
`[{"identifier":"a1","full_name":"Ada Lovelace","emails":["ada@example.com"],"phones":["+1 555 0100"]}]`,
"{\"identifier\":\"a1\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"emails\":[\"ada@example.com\"]}\n",
} {
contacts, err := Decode(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Name() != "Ada Lovelace" {
t.Fatalf("contacts = %#v", contacts)
}
src := contacts[0].SourceContact()
if src.Source != "apple" || src.ExternalID != "a1" || src.Name != "Ada Lovelace" {
t.Fatalf("source = %#v", src)
}
}
}
func TestReadFileAndToSourceContacts(t *testing.T) {
path := filepath.Join(t.TempDir(), "contacts.ndjson")
if err := os.WriteFile(path, []byte("{\"full_name\":\"Ada\",\"emails\":[\"ada@example.com\"]}\n{\"phones\":[\"+1\"]}\n"), 0o600); err != nil {
t.Fatal(err)
}
contacts, err := ReadFile(path)
if err != nil {
t.Fatal(err)
}
sources := ToSourceContacts(contacts)
if len(sources) != 1 || sources[0].Name != "Ada" {
t.Fatalf("sources = %#v", sources)
}
}
func TestDecodeEmptyAndInvalid(t *testing.T) {
contacts, err := Decode(strings.NewReader(" \n"))
if err != nil || len(contacts) != 0 {
t.Fatalf("contacts=%#v err=%v", contacts, err)
}
if _, err := Decode(strings.NewReader("{bad")); err == nil {
t.Fatal("expected invalid json error")
}
if _, err := ReadFile(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected read file error")
}
}

View File

@ -0,0 +1,84 @@
import Contacts
import Foundation
struct ClawdexContact: Codable {
let identifier: String
let first_name: String
let last_name: String
let full_name: String
let emails: [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] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
]
let request = CNContactFetchRequest(keysToFetch: keys)
let encoder = JSONEncoder()
do {
try store.enumerateContacts(with: request) { contact, _ in
let emails = contact.emailAddresses.map { String($0.value) }.filter { !$0.isEmpty }
let phones = contact.phoneNumbers.map { $0.value.stringValue }.filter { !$0.isEmpty }
guard !emails.isEmpty || !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 = ClawdexContact(
identifier: contact.identifier,
first_name: contact.givenName,
last_name: contact.familyName,
full_name: fullName,
emails: emails,
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)")
}

57
internal/apple/darwin.go Normal file
View File

@ -0,0 +1,57 @@
//go:build darwin
package apple
import (
"bytes"
"context"
_ "embed"
"errors"
"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("", "clawdex-contacts-*")
if err != nil {
return nil, err
}
defer func() { _ = os.RemoveAll(dir) }()
script := filepath.Join(dir, "contacts-export.swift")
if err := os.WriteFile(script, []byte(contactsExportSwift), 0o600); err != nil {
return nil, err
}
out, err := runSwiftContacts(ctx, script)
if err != nil {
return nil, err
}
return Decode(bytes.NewReader(out))
}
var runSwiftContacts = func(ctx context.Context, script string) ([]byte, error) {
cmd := swiftCommand(ctx, script)
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("read macOS Contacts: %s", string(ee.Stderr))
}
return nil, fmt.Errorf("run swift Contacts helper: %w", err)
}
return out, nil
}
func swiftCommand(ctx context.Context, script string) *exec.Cmd {
if path, err := exec.LookPath("swift"); err == nil {
// #nosec G204 -- swift is resolved from PATH and the generated script is passed as an argument.
return exec.CommandContext(ctx, path, script)
}
// #nosec G204 -- xcrun is fixed and the generated script is passed as an argument.
return exec.CommandContext(ctx, "xcrun", "swift", script)
}

View File

@ -0,0 +1,46 @@
//go:build darwin
package apple
import (
"context"
"errors"
"strings"
"testing"
)
func TestReadSystemUsesSwiftRunner(t *testing.T) {
orig := runSwiftContacts
t.Cleanup(func() { runSwiftContacts = orig })
runSwiftContacts = func(ctx context.Context, script string) ([]byte, error) {
if !strings.HasSuffix(script, "contacts-export.swift") {
t.Fatalf("script = %s", script)
}
return []byte(`{"identifier":"a1","full_name":"Ada","emails":["ada@example.com"]}` + "\n"), nil
}
contacts, err := ReadSystem(t.Context())
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Name() != "Ada" {
t.Fatalf("contacts = %#v", contacts)
}
}
func TestReadSystemPropagatesSwiftError(t *testing.T) {
orig := runSwiftContacts
t.Cleanup(func() { runSwiftContacts = orig })
runSwiftContacts = func(context.Context, string) ([]byte, error) {
return nil, errors.New("denied")
}
if _, err := ReadSystem(t.Context()); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("err = %v", err)
}
}
func TestSwiftCommand(t *testing.T) {
cmd := swiftCommand(t.Context(), "/tmp/test.swift")
if cmd == nil || len(cmd.Args) == 0 {
t.Fatalf("cmd = %#v", cmd)
}
}

12
internal/apple/other.go Normal file
View File

@ -0,0 +1,12 @@
//go:build !darwin
package apple
import (
"context"
"errors"
)
func ReadSystem(context.Context) ([]Contact, error) {
return nil, errors.New("apple contacts are only supported on macOS; use vCard import/export fallback on this platform")
}

View File

@ -0,0 +1,12 @@
//go:build !darwin
package apple
import "testing"
func TestReadSystemUnsupported(t *testing.T) {
_, err := ReadSystem(t.Context())
if err == nil {
t.Fatal("expected unsupported error")
}
}

View File

@ -0,0 +1,133 @@
package birdclaw
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/openclaw/clawdex/internal/model"
)
type Adapter struct {
DBPath string
Binary string
}
type dmRow struct {
ConversationID string `json:"conversation_id"`
ProfileID string `json:"profile_id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
Title string `json:"title"`
Messages int `json:"messages"`
FirstMessage string `json:"first_message"`
LastMessage string `json:"last_message"`
}
func (a Adapter) ListDMContacts(ctx context.Context, minMessages int) ([]model.SourceContact, error) {
if minMessages < 1 {
minMessages = 1
}
dbPath, err := resolveDBPath(a.DBPath)
if err != nil {
return nil, err
}
binary := strings.TrimSpace(a.Binary)
if binary == "" {
binary = "sqlite3"
}
query := fmt.Sprintf(dmQuery, minMessages)
// #nosec G204 -- sqlite3 is a configured binary and all arguments are passed without a shell.
cmd := exec.CommandContext(ctx, binary, "-json", dbPath, query)
raw, err := cmd.Output()
if err != nil {
return nil, sqliteErr(err)
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, nil
}
var rows []dmRow
if err := json.Unmarshal(raw, &rows); err != nil {
return nil, err
}
out := make([]model.SourceContact, 0, len(rows))
for _, row := range rows {
name := firstNonEmpty(row.DisplayName, row.Title, row.Handle)
if name == "" || row.ConversationID == "" {
continue
}
accounts := map[string][]string{"x": {"dm:" + row.ConversationID}}
if row.Handle != "" {
accounts["x"] = append(accounts["x"], "@"+strings.TrimPrefix(row.Handle, "@"))
}
if row.ProfileID != "" {
accounts["x"] = append(accounts["x"], "user:"+row.ProfileID)
}
out = append(out, model.SourceContact{
Source: "x",
ExternalID: row.ConversationID,
Name: name,
Tags: []string{"x", "dm"},
Accounts: accounts,
})
}
return out, nil
}
func resolveDBPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(home, ".birdclaw", "birdclaw.sqlite")
}
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
return filepath.Abs(path)
}
func sqliteErr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("birdclaw sqlite query: %s", strings.TrimSpace(string(exitErr.Stderr)))
}
return err
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
const dmQuery = `
select
c.id as conversation_id,
c.participant_profile_id as profile_id,
coalesce(p.handle, '') as handle,
coalesce(p.display_name, '') as display_name,
c.title as title,
count(m.id) as messages,
min(m.created_at) as first_message,
max(m.created_at) as last_message
from dm_conversations c
join dm_messages m on m.conversation_id = c.id
left join profiles p on p.id = c.participant_profile_id
group by c.id, c.participant_profile_id, p.handle, p.display_name, c.title
having count(m.id) > %d
order by messages desc, lower(coalesce(nullif(p.display_name, ''), nullif(c.title, ''), p.handle));
`

View File

@ -0,0 +1,84 @@
package birdclaw
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestListDMContactsViaSQLiteAdapter(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
script := `#!/bin/sh
case "$*" in
*'count(m.id) > 4'*) ;;
*) echo "missing threshold" >&2; exit 2 ;;
esac
cat <<'JSON'
[{"conversation_id":"1-2","profile_id":"2","handle":"ada","display_name":"Ada Lovelace","title":"ada","messages":5}]
JSON
`
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
contacts, err := (Adapter{DBPath: "~/birdclaw.sqlite", Binary: bin}).ListDMContacts(t.Context(), 4)
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Name != "Ada Lovelace" {
t.Fatalf("contacts = %#v", contacts)
}
accounts := contacts[0].Accounts["x"]
if len(accounts) != 3 || accounts[1] != "@ada" || accounts[2] != "user:2" {
t.Fatalf("accounts = %#v", accounts)
}
}
func TestListDMContactsFallbacksAndFilters(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(bin, []byte(`#!/bin/sh
cat <<'JSON'
[{"conversation_id":"c1","profile_id":"","handle":"handle","display_name":"","title":"","messages":8},{"conversation_id":"","handle":"skip","messages":8},{"conversation_id":"c2","handle":"","display_name":"","title":" ","messages":8}]
JSON
`), 0o700); err != nil {
t.Fatal(err)
}
contacts, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), -1)
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Name != "handle" {
t.Fatalf("contacts = %#v", contacts)
}
empty := filepath.Join(t.TempDir(), "sqlite3-empty")
if err := os.WriteFile(empty, []byte("#!/bin/sh\nprintf ' '\n"), 0o700); err != nil {
t.Fatal(err)
}
contacts, err = (Adapter{Binary: empty}).ListDMContacts(t.Context(), 4)
if err != nil || len(contacts) != 0 {
t.Fatalf("contacts=%#v err=%v", contacts, err)
}
}
func TestListDMContactsErrors(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), 4); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("err = %v", err)
}
bad := filepath.Join(t.TempDir(), "sqlite3-bad")
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho not-json\n"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := (Adapter{Binary: bad}).ListDMContacts(t.Context(), 4); err == nil {
t.Fatal("expected json error")
}
}
func TestFirstNonEmpty(t *testing.T) {
if got := firstNonEmpty("", " ", "x"); got != "x" {
t.Fatalf("got = %q", got)
}
}

704
internal/cli/cli.go Normal file
View File

@ -0,0 +1,704 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/alecthomas/kong"
"github.com/openclaw/clawdex/internal/apple"
"github.com/openclaw/clawdex/internal/birdclaw"
"github.com/openclaw/clawdex/internal/discrawl"
"github.com/openclaw/clawdex/internal/google"
"github.com/openclaw/clawdex/internal/index"
"github.com/openclaw/clawdex/internal/markdown"
"github.com/openclaw/clawdex/internal/model"
"github.com/openclaw/clawdex/internal/repo"
"github.com/openclaw/clawdex/internal/vcard"
)
var Version = "dev"
type CLI struct {
Config string `name:"config" help:"Config path" env:"CLAWDEX_CONFIG"`
Repo string `name:"repo" help:"Contacts data repo path" env:"CLAWDEX_REPO"`
JSON bool `name:"json" help:"Write JSON to stdout"`
Plain bool `name:"plain" help:"Write stable plain text to stdout"`
DryRun bool `name:"dry-run" short:"n" help:"Preview changes without writing"`
NoInput bool `name:"no-input" help:"Never prompt"`
Verbose bool `name:"verbose" short:"v" help:"Verbose diagnostics"`
Version kong.VersionFlag `name:"version" help:"Print version and exit"`
Init InitCmd `cmd:"" help:"Initialize a contacts data repo"`
ConfigC ConfigCmd `cmd:"" name:"config" help:"Show or edit clawdex config"`
Person PersonCmd `cmd:"" help:"Manage people"`
Note NoteCmd `cmd:"" help:"Manage notes"`
Timeline TimelineCmd `cmd:"" help:"Show person timeline"`
Search SearchCmd `cmd:"" help:"Search people and notes"`
Import ImportCmd `cmd:"" help:"Import contacts into local markdown"`
Sync SyncCmd `cmd:"" help:"Preview sync with address books"`
Export ExportCmd `cmd:"" help:"Export contacts"`
Git GitCmd `cmd:"" help:"Run data repo git helpers"`
Doctor DoctorCmd `cmd:"" help:"Check repo health"`
}
type Runtime struct {
ctx context.Context
stdout io.Writer
stderr io.Writer
root *CLI
configPath string
cfg repo.Config
repo repo.Repo
store index.Store
}
func Execute(args []string, stdout, stderr io.Writer) error {
var root CLI
parser, err := kong.New(&root,
kong.Name("clawdex"),
kong.Description("Personal contact index backed by markdown and private Git."),
kong.UsageOnError(),
kong.Writers(stdout, stderr),
kong.Vars{"version": Version},
)
if err != nil {
return err
}
kctx, err := parser.Parse(args)
if err != nil {
return usageErr{err}
}
configPath := repo.ResolveConfigPath(root.Config)
cfg, err := repo.LoadConfig(configPath)
if err != nil {
return err
}
repoPath, err := repo.ResolveRepoPath(root.Repo, cfg)
if err != nil {
repoPath = cfg.RepoPath
}
r := &Runtime{
ctx: context.Background(),
stdout: stdout,
stderr: stderr,
root: &root,
configPath: configPath,
cfg: cfg,
repo: repo.Open(repoPath, cfg),
}
r.store = index.New(r.repo)
kctx.Bind(r)
if err := kctx.Run(r); err != nil {
return err
}
return nil
}
func ExitCode(err error) int {
if err == nil {
return 0
}
var usage usageErr
if errors.As(err, &usage) {
return 2
}
return 1
}
type usageErr struct{ error }
type InitCmd struct {
Dir string `arg:"" optional:"" help:"Contacts data repo directory"`
Remote string `name:"remote" help:"Git remote for contacts backup"`
NoConfig bool `name:"no-config" help:"Do not write app config"`
}
func (c *InitCmd) Run(r *Runtime) error {
cfg := r.cfg
if c.Dir != "" {
cfg.RepoPath = c.Dir
}
if c.Remote != "" {
cfg.Git.Remote = c.Remote
}
cfg.Normalize()
dataRepo := repo.Open(cfg.RepoPath, cfg)
if err := dataRepo.Init(r.ctx); err != nil {
return err
}
if !c.NoConfig {
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
return err
}
}
return r.print(map[string]any{"repo_path": cfg.RepoPath, "remote": cfg.Git.Remote, "config_path": r.configPath})
}
type ConfigCmd struct {
Show ConfigShowCmd `cmd:"" default:"1" help:"Show config"`
Set ConfigSetCmd `cmd:"" help:"Set config value"`
}
type ConfigShowCmd struct{}
func (c *ConfigShowCmd) Run(r *Runtime) error {
return r.print(r.cfg)
}
type ConfigSetCmd struct {
Key string `arg:"" help:"Config key"`
Value string `arg:"" help:"Config value"`
}
func (c *ConfigSetCmd) Run(r *Runtime) error {
cfg := r.cfg
switch c.Key {
case "repo_path":
cfg.RepoPath = c.Value
case "git.remote":
cfg.Git.Remote = c.Value
case "git.branch":
cfg.Git.Branch = c.Value
case "google.default_account":
cfg.Google.DefaultAccount = c.Value
default:
return usageErr{fmt.Errorf("unsupported config key %q", c.Key)}
}
if r.root.DryRun {
return r.print(cfg)
}
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
return err
}
return r.print(map[string]any{"config_path": r.configPath, "set": c.Key})
}
type PersonCmd struct {
Add PersonAddCmd `cmd:"" help:"Add a person"`
List PersonListCmd `cmd:"" help:"List people"`
Show PersonShowCmd `cmd:"" help:"Show a person"`
Edit PersonEditCmd `cmd:"" help:"Edit a person markdown file"`
}
type PersonAddCmd struct {
Name string `arg:"" help:"Person name"`
Email []string `name:"email" short:"e" help:"Email address"`
Phone []string `name:"phone" short:"p" help:"Phone number"`
Tag []string `name:"tag" short:"t" help:"Tag"`
}
func (c *PersonAddCmd) Run(r *Runtime) error {
if err := r.repo.Require(); err != nil {
return err
}
if r.root.DryRun {
return r.print(map[string]any{"would_create": c.Name})
}
p, err := r.store.AddPerson(c.Name, c.Email, c.Phone, c.Tag, time.Now())
if err != nil {
return err
}
return r.printPerson(p)
}
type PersonListCmd struct {
Query string `name:"query" short:"q" help:"Filter query"`
}
func (c *PersonListCmd) Run(r *Runtime) error {
people, err := r.store.People()
if err != nil {
return err
}
if c.Query != "" {
filtered := people[:0]
q := strings.ToLower(c.Query)
for _, p := range people {
if strings.Contains(strings.ToLower(p.Name+" "+p.ID+" "+strings.Join(p.Tags, " ")), q) {
filtered = append(filtered, p)
}
}
people = filtered
}
return r.printPeople(people)
}
type PersonShowCmd struct {
Query string `arg:"" help:"ID, name, email, or phone"`
}
func (c *PersonShowCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Query)
if err != nil {
return err
}
return r.printPerson(p)
}
type PersonEditCmd struct {
Query string `arg:"" help:"ID, name, email, or phone"`
}
func (c *PersonEditCmd) Run(r *Runtime) error {
p, err := r.store.FindPerson(c.Query)
if err != nil {
return err
}
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
editor = "code"
}
// #nosec G204,G702 -- EDITOR is a deliberate user-controlled executable; no shell is involved.
cmd := exec.CommandContext(r.ctx, editor, p.Path)
cmd.Stdout = r.stdout
cmd.Stderr = r.stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
type NoteCmd struct {
Add NoteAddCmd `cmd:"" help:"Add a note"`
List NoteListCmd `cmd:"" help:"List notes"`
}
type NoteAddCmd struct {
Person string `arg:"" help:"Person query"`
Kind string `name:"kind" required:"" help:"Note kind"`
Source string `name:"source" required:"" help:"Note source"`
Text string `name:"text" help:"Note body"`
OccurredAt string `name:"occurred-at" help:"Occurrence time"`
Topic []string `name:"topic" help:"Topic"`
}
func (c *NoteAddCmd) Run(r *Runtime) error {
if c.Text == "" {
return usageErr{errors.New("--text is required")}
}
occurredAt, err := parseOptionalTime(c.OccurredAt)
if err != nil {
return err
}
n := markdown.NewNote("", c.Kind, c.Source, c.Text, occurredAt, time.Now(), c.Topic)
if r.root.DryRun {
return r.print(n)
}
n, err = r.store.AddNote(c.Person, n)
if err != nil {
return err
}
return r.print(n)
}
type NoteListCmd struct {
Person string `arg:"" help:"Person query"`
}
func (c *NoteListCmd) Run(r *Runtime) error {
notes, err := r.store.Notes(c.Person)
if err != nil {
return err
}
return r.print(notes)
}
type TimelineCmd struct {
Person string `arg:"" help:"Person query"`
}
func (c *TimelineCmd) Run(r *Runtime) error {
notes, err := r.store.Notes(c.Person)
if err != nil {
return err
}
return r.printTimeline(notes)
}
type SearchCmd struct {
Query string `arg:"" help:"Search query"`
}
func (c *SearchCmd) Run(r *Runtime) error {
hits, err := r.store.Search(c.Query)
if err != nil {
return err
}
return r.print(hits)
}
type ImportCmd struct {
Apple ImportAppleCmd `cmd:"" help:"Import Apple Contacts into local markdown"`
Birdclaw ImportBirdclawCmd `cmd:"" help:"Import X/Twitter DM contacts from local birdclaw archive"`
Google ImportGoogleCmd `cmd:"" help:"Import Google Contacts into local markdown"`
Discrawl ImportDiscrawlCmd `cmd:"" help:"Import Discord DM contacts from local discrawl archive"`
}
type ImportAppleCmd struct {
Input string `name:"input" help:"JSON/NDJSON contact file instead of macOS Contacts"`
}
func (c *ImportAppleCmd) Run(r *Runtime) error {
var contacts []apple.Contact
var err error
if c.Input != "" {
contacts, err = apple.ReadFile(c.Input)
} else {
contacts, err = apple.ReadSystem(r.ctx)
}
if err != nil {
return err
}
changes, err := r.store.ImportContacts("apple", apple.ToSourceContacts(contacts), r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type ImportGoogleCmd struct {
Account string `name:"account" help:"Google account email"`
}
func (c *ImportGoogleCmd) Run(r *Runtime) error {
account := c.Account
if account == "" {
account = r.cfg.Google.DefaultAccount
}
contacts, err := (google.GogAdapter{}).ListContacts(r.ctx, account)
if err != nil {
return err
}
changes, err := r.store.ImportContacts("google", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type ImportDiscrawlCmd struct {
DBPath string `name:"db" help:"discrawl SQLite database path" default:"~/.discrawl/discrawl.db"`
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
}
type ImportBirdclawCmd struct {
DBPath string `name:"db" help:"birdclaw SQLite database path" default:"~/.birdclaw/birdclaw.sqlite"`
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
}
func (c *ImportBirdclawCmd) Run(r *Runtime) error {
contacts, err := (birdclaw.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
if err != nil {
return err
}
changes, err := r.store.ImportContacts("x", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
func (c *ImportDiscrawlCmd) Run(r *Runtime) error {
contacts, err := (discrawl.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
if err != nil {
return err
}
changes, err := r.store.ImportContacts("discord", contacts, r.root.DryRun, time.Now())
if err != nil {
return err
}
return r.print(changes)
}
type SyncCmd struct {
Apple SyncAppleCmd `cmd:"" help:"Preview Apple Contacts sync"`
Google SyncGoogleCmd `cmd:"" help:"Preview Google Contacts sync"`
}
type SyncAppleCmd struct{}
func (c *SyncAppleCmd) Run(r *Runtime) error {
return r.print(map[string]any{"dry_run": true, "status": "remote writes not implemented yet; use import apple for local markdown projection"})
}
type SyncGoogleCmd struct {
Account string `name:"account" help:"Google account email"`
}
func (c *SyncGoogleCmd) Run(r *Runtime) error {
return r.print(map[string]any{"dry_run": true, "account": firstNonEmpty(c.Account, r.cfg.Google.DefaultAccount), "status": "remote writes not implemented yet; use import google for local markdown projection"})
}
type ExportCmd struct {
VCard ExportVCardCmd `cmd:"" name:"vcard" help:"Export vCard"`
}
type ExportVCardCmd struct {
Person string `name:"person" help:"Person query"`
All bool `name:"all" help:"Export all people"`
Out string `name:"out" short:"o" required:"" help:"Output .vcf path, or - for stdout"`
}
func (c *ExportVCardCmd) Run(r *Runtime) error {
var people []model.Person
switch {
case c.All:
var err error
people, err = r.store.People()
if err != nil {
return err
}
case c.Person != "":
p, err := r.store.FindPerson(c.Person)
if err != nil {
return err
}
people = []model.Person{p}
default:
return usageErr{errors.New("provide --person or --all")}
}
if c.Out == "-" {
return vcard.Write(r.stdout, people)
}
f, err := os.Create(c.Out)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if err := vcard.Write(f, people); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return r.print(map[string]any{"exported": len(people), "out": c.Out})
}
type GitCmd struct {
Status GitStatusCmd `cmd:"" default:"1" help:"Show git status"`
Pull GitPullCmd `cmd:"" help:"Pull data repo"`
Push GitPushCmd `cmd:"" help:"Push data repo"`
Commit GitCommitCmd `cmd:"" help:"Commit data repo changes"`
}
type GitStatusCmd struct{}
func (c *GitStatusCmd) Run(r *Runtime) error {
// #nosec G204 -- git is fixed and repo path is passed as a plain argument.
cmd := exec.CommandContext(r.ctx, "git", "-C", r.repo.Path, "status", "--short", "--branch")
cmd.Stdout = r.stdout
cmd.Stderr = r.stderr
return cmd.Run()
}
type GitPullCmd struct{}
func (c *GitPullCmd) Run(r *Runtime) error {
return r.repo.Pull(r.ctx)
}
type GitPushCmd struct{}
func (c *GitPushCmd) Run(r *Runtime) error {
return r.repo.Push(r.ctx)
}
type GitCommitCmd struct {
Message string `name:"message" short:"m" help:"Commit message" default:"sync: update clawdex contacts"`
}
func (c *GitCommitCmd) Run(r *Runtime) error {
committed, err := r.repo.Commit(r.ctx, c.Message)
if err != nil {
return err
}
return r.print(map[string]any{"committed": committed})
}
type DoctorCmd struct {
Repair bool `name:"repair" help:"Repair damaged markdown frontmatter"`
}
func (c *DoctorCmd) Run(r *Runtime) error {
store := r.store
if c.Repair {
store.Repo.Config.Repair.AutoRepair = false
}
people, err := store.People()
if err != nil {
return err
}
dirty, _ := r.repo.Dirty(r.ctx)
result := map[string]any{
"config_path": r.configPath,
"repo_path": r.repo.Path,
"remote": r.cfg.Git.Remote,
"people": len(people),
"git_dirty": dirty,
}
if c.Repair {
var repaired int
for _, p := range people {
loaded, report, err := markdown.ReadPerson(p.Path)
if err != nil {
return err
}
if report.Needed {
repaired++
if !r.root.DryRun {
if err := markdown.RepairPerson(p.Path, r.repo.RepairDir(), loaded, report, r.cfg.Repair.BackupBeforeRepair); err != nil {
return err
}
}
}
}
result["repaired"] = repaired
result["dry_run"] = r.root.DryRun
}
return r.print(result)
}
func (r *Runtime) print(value any) error {
if r.root.JSON {
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
switch v := value.(type) {
case map[string]any:
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, err := fmt.Fprintf(r.stdout, "%s: %v\n", key, v[key]); err != nil {
return err
}
}
return nil
case model.Note:
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", v.ID, v.Kind, v.Source, v.Path)
return err
case []model.Note:
return r.printTimeline(v)
case []model.SearchHit:
return r.printHits(v)
case []model.ImportChange:
for _, change := range v {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", change.Action, change.Name, change.PersonID); err != nil {
return err
}
}
return nil
default:
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
}
func (r *Runtime) printPerson(p model.Person) error {
if r.root.JSON {
return r.print(p)
}
if r.root.Plain {
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path)
return err
}
if _, err := fmt.Fprintf(r.stdout, "id: %s\nname: %s\npath: %s\n", p.ID, p.Name, p.Path); err != nil {
return err
}
for _, email := range p.Emails {
if _, err := fmt.Fprintf(r.stdout, "email: %s\n", email.Value); err != nil {
return err
}
}
for _, phone := range p.Phones {
if _, err := fmt.Fprintf(r.stdout, "phone: %s\n", phone.Value); err != nil {
return err
}
}
return nil
}
func (r *Runtime) printPeople(people []model.Person) error {
if r.root.JSON {
return r.print(people)
}
for _, p := range people {
if r.root.Plain {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, firstEmail(p)); err != nil {
return err
}
}
}
return nil
}
func (r *Runtime) printTimeline(notes []model.Note) error {
if r.root.JSON {
return r.print(notes)
}
for _, n := range notes {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", n.OccurredAt.Format(time.RFC3339), n.Kind, n.Source, strings.ReplaceAll(n.Body, "\n", " ")); err != nil {
return err
}
}
return nil
}
func (r *Runtime) printHits(hits []model.SearchHit) error {
for _, hit := range hits {
if r.root.Plain {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.ID, hit.Name, hit.Path); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.Name, hit.Snippet, hit.Path); err != nil {
return err
}
}
}
return nil
}
func firstEmail(p model.Person) string {
if len(p.Emails) == 0 {
return ""
}
return p.Emails[0].Value
}
func parseOptionalTime(value string) (time.Time, error) {
if strings.TrimSpace(value) == "" {
return time.Time{}, nil
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04", "2006-01-02"} {
t, err := time.Parse(layout, value)
if err == nil {
return t, nil
}
}
return time.Time{}, usageErr{fmt.Errorf("invalid time %q", value)}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

557
internal/cli/cli_test.go Normal file
View File

@ -0,0 +1,557 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/openclaw/clawdex/internal/model"
)
func TestExecuteEndToEndLocalCommands(t *testing.T) {
cfg, data := testPaths(t)
run := func(args ...string) string {
t.Helper()
var out, errOut bytes.Buffer
full := append([]string{"--config", cfg}, args...)
if err := Execute(full, &out, &errOut); err != nil {
t.Fatalf("Execute(%v): %v stderr=%s stdout=%s", full, err, errOut.String(), out.String())
}
return out.String()
}
out := run("init", data, "--remote", "")
if !strings.Contains(out, "repo_path:") {
t.Fatalf("init out = %s", out)
}
out = run("person", "add", "Ada Lovelace", "--email", "ada@example.com", "--phone", "+1 555 0100", "--tag", "math")
if !strings.Contains(out, "Ada Lovelace") {
t.Fatalf("add out = %s", out)
}
out = run("person", "list", "--plain")
if !strings.Contains(out, "Ada Lovelace") {
t.Fatalf("list out = %s", out)
}
out = run("person", "show", "ada@example.com")
if !strings.Contains(out, "email: ada@example.com") {
t.Fatalf("show out = %s", out)
}
out = run("note", "add", "ada", "--kind", "dm", "--source", "manual", "--text", "Analytical engine")
if !strings.Contains(out, "dm\tmanual") {
t.Fatalf("note out = %s", out)
}
out = run("note", "list", "ada")
if !strings.Contains(out, "Analytical engine") {
t.Fatalf("notes out = %s", out)
}
out = run("timeline", "ada")
if !strings.Contains(out, "Analytical engine") {
t.Fatalf("timeline out = %s", out)
}
out = run("search", "engine", "--plain")
if !strings.Contains(out, "note") {
t.Fatalf("search out = %s", out)
}
vcardPath := filepath.Join(t.TempDir(), "contacts.vcf")
out = run("export", "vcard", "--all", "-o", vcardPath)
if !strings.Contains(out, "exported: 1") {
t.Fatalf("export out = %s", out)
}
if data, err := os.ReadFile(vcardPath); err != nil || !strings.Contains(string(data), "BEGIN:VCARD") {
t.Fatalf("vcard data=%q err=%v", data, err)
}
out = run("sync", "apple")
if !strings.Contains(out, "remote writes not implemented") {
t.Fatalf("sync out = %s", out)
}
out = run("sync", "google", "--account", "me@example.com")
if !strings.Contains(out, "me@example.com") {
t.Fatalf("sync google out = %s", out)
}
out = run("doctor")
if !strings.Contains(out, "people: 1") {
t.Fatalf("doctor out = %s", out)
}
out = run("git", "commit", "-m", "test: contacts")
if !strings.Contains(out, "committed: true") {
t.Fatalf("git commit out = %s", out)
}
}
func TestExecuteConfigJSONAndUsage(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "--json", "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatalf("init: %v %s", err, errOut.String())
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("json = %s err=%v", out.String(), err)
}
out.Reset()
if err := Execute([]string{"--config", cfg, "config", "set", "git.branch", "main"}, &out, &errOut); err != nil {
t.Fatal(err)
}
out.Reset()
if err := Execute([]string{"--config", cfg, "--dry-run", "config", "set", "google.default_account", "me@example.com"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "me@example.com") {
t.Fatalf("dry config = %s", out.String())
}
out.Reset()
if err := Execute([]string{"--config", cfg, "--json", "config", "show"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), `"branch": "main"`) {
t.Fatalf("config = %s", out.String())
}
if err := Execute([]string{"--config", cfg, "config", "set", "nope", "x"}, &out, &errOut); err == nil || ExitCode(err) != 2 {
t.Fatalf("expected usage err, got %v", err)
}
if err := Execute([]string{"--bogus"}, &out, &errOut); err == nil || ExitCode(err) != 2 {
t.Fatalf("expected parse usage err, got %v", err)
}
badCfg := filepath.Join(t.TempDir(), "bad.toml")
if err := os.WriteFile(badCfg, []byte("["), 0o600); err != nil {
t.Fatal(err)
}
if err := Execute([]string{"--config", badCfg, "config"}, &out, &errOut); err == nil {
t.Fatal("expected config parse error")
}
if ExitCode(nil) != 0 {
t.Fatal("nil exit code")
}
}
func TestExecuteImportAppleFromFileAndGoogleViaFakeGog(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
input := filepath.Join(t.TempDir(), "apple.ndjson")
if err := os.WriteFile(input, []byte("{\"identifier\":\"a1\",\"full_name\":\"Ada Apple\",\"emails\":[\"apple@example.com\"]}\n"), 0o600); err != nil {
t.Fatal(err)
}
out.Reset()
if err := Execute([]string{"--config", cfg, "import", "apple", "--input", input}, &out, &errOut); err != nil {
t.Fatalf("apple import: %v %s", err, errOut.String())
}
if !strings.Contains(out.String(), "create\tAda Apple") {
t.Fatalf("apple import out = %s", out.String())
}
fakeGog := writeFakeGog(t, `[{"resourceName":"people/g1","name":"Grace Google","email":"grace@example.com"}]`)
t.Setenv("PATH", filepath.Dir(fakeGog)+string(os.PathListSeparator)+os.Getenv("PATH"))
out.Reset()
if err := Execute([]string{"--config", cfg, "import", "google", "--account", "me@example.com"}, &out, &errOut); err != nil {
t.Fatalf("google import: %v %s", err, errOut.String())
}
if !strings.Contains(out.String(), "create\tGrace Google") {
t.Fatalf("google import out = %s", out.String())
}
out.Reset()
if err := Execute([]string{"--config", cfg, "person", "show", "grace@example.com"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "Grace Google") {
t.Fatalf("show = %s", out.String())
}
fakeSQLite := writeFakeSQLite(t, `[{"channel_id":"dm1","name":"Discord Friend","messages":5,"counterpart_id":"user1"}]`)
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
out.Reset()
if err := Execute([]string{"--config", cfg, "import", "discrawl", "--db", filepath.Join(t.TempDir(), "discrawl.db"), "--min-messages", "4"}, &out, &errOut); err != nil {
t.Fatalf("discrawl import: %v %s", err, errOut.String())
}
if !strings.Contains(out.String(), "create\tDiscord Friend") {
t.Fatalf("discrawl import out = %s", out.String())
}
fakeBirdclaw := writeFakeSQLite(t, `[{"conversation_id":"1-2","profile_id":"2","handle":"bird","display_name":"Bird Person","messages":5}]`)
t.Setenv("PATH", filepath.Dir(fakeBirdclaw)+string(os.PathListSeparator)+os.Getenv("PATH"))
out.Reset()
if err := Execute([]string{"--config", cfg, "import", "birdclaw", "--db", filepath.Join(t.TempDir(), "birdclaw.sqlite"), "--min-messages", "4"}, &out, &errOut); err != nil {
t.Fatalf("birdclaw import: %v %s", err, errOut.String())
}
if !strings.Contains(out.String(), "create\tBird Person") {
t.Fatalf("birdclaw import out = %s", out.String())
}
}
func TestExecuteGitStatusAndDryRun(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
out.Reset()
if err := Execute([]string{"--config", cfg, "--dry-run", "person", "add", "Dry Run"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "would_create: Dry Run") {
t.Fatalf("dry run = %s", out.String())
}
out.Reset()
if err := Execute([]string{"--config", cfg, "git"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "No commits yet") {
t.Fatalf("git status = %s", out.String())
}
}
func TestExecuteImportDiscrawlErrors(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
fakeSQLite := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(fakeSQLite, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := Execute([]string{"--config", cfg, "import", "discrawl", "--db", filepath.Join(t.TempDir(), "discrawl.db")}, &out, &errOut); err == nil {
t.Fatal("expected discrawl import error")
}
}
func TestExecuteImportBirdclawErrors(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
fakeSQLite := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(fakeSQLite, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := Execute([]string{"--config", cfg, "import", "birdclaw", "--db", filepath.Join(t.TempDir(), "birdclaw.sqlite")}, &out, &errOut); err == nil {
t.Fatal("expected birdclaw import error")
}
}
func TestExecuteJSONPlainAndStdoutBranches(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
must := func(args ...string) string {
t.Helper()
out.Reset()
errOut.Reset()
if err := Execute(append([]string{"--config", cfg}, args...), &out, &errOut); err != nil {
t.Fatalf("%v: %v stderr=%s", args, err, errOut.String())
}
return out.String()
}
must("init", data, "--remote", "")
must("person", "add", "Ada JSON", "--email", "json@example.com")
must("person", "add", "Empty Email")
if got := must("--json", "person", "show", "json@example.com"); !strings.Contains(got, `"name": "Ada JSON"`) {
t.Fatalf("json show = %s", got)
}
if got := must("--json", "person", "list", "--query", "Ada"); !strings.Contains(got, `"Ada JSON"`) {
t.Fatalf("json list = %s", got)
}
if got := must("--plain", "person", "show", "json@example.com"); !strings.Contains(got, "Ada JSON") {
t.Fatalf("plain show = %s", got)
}
if got := must("--plain", "person", "list", "--query", "NoMatch"); got != "" {
t.Fatalf("empty list = %s", got)
}
if got := must("person", "list", "--query", "Empty"); !strings.Contains(got, "Empty Email") {
t.Fatalf("no-email list = %s", got)
}
must("note", "add", "json@example.com", "--kind", "call", "--source", "manual", "--text", "Call body", "--occurred-at", "2026-05-08 10:00")
if got := must("--json", "note", "list", "json@example.com"); !strings.Contains(got, `"kind": "call"`) {
t.Fatalf("json notes = %s", got)
}
if got := must("export", "vcard", "--person", "json@example.com", "-o", "-"); !strings.Contains(got, "BEGIN:VCARD") {
t.Fatalf("stdout vcard = %s", got)
}
input := filepath.Join(t.TempDir(), "apple.ndjson")
if err := os.WriteFile(input, []byte("{\"identifier\":\"a1\",\"full_name\":\"Dry Apple\",\"emails\":[\"dry@example.com\"]}\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := must("--dry-run", "import", "apple", "--input", input); !strings.Contains(got, "create\tDry Apple") {
t.Fatalf("dry import = %s", got)
}
}
func TestPrintHelpersCoverPlainJSONAndWriteErrors(t *testing.T) {
var out bytes.Buffer
person := model.Person{
ID: "person_1",
Name: "Print Person",
Path: "/tmp/person.md",
Emails: []model.ContactValue{{Value: "print@example.com"}},
}
note := model.Note{
ID: "note_1",
Kind: "note",
Source: "manual",
OccurredAt: time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC),
Body: "line one\nline two",
}
hit := model.SearchHit{Kind: "note", ID: "note_1", Name: "Print Person", Snippet: "line", Path: "/tmp/note.md"}
r := &Runtime{stdout: &out, root: &CLI{}}
if err := r.printPeople([]model.Person{person}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "print@example.com") {
t.Fatalf("people out = %s", out.String())
}
out.Reset()
if err := r.printTimeline([]model.Note{note}); err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "\nline two") {
t.Fatalf("timeline did not flatten body = %s", out.String())
}
out.Reset()
if err := r.printHits([]model.SearchHit{hit}); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "line") {
t.Fatalf("hits out = %s", out.String())
}
out.Reset()
r.root.Plain = true
if err := r.printPeople([]model.Person{{ID: "person_2", Name: "No Email", Path: "/tmp/no.md"}}); err != nil {
t.Fatal(err)
}
if err := r.printHits([]model.SearchHit{hit}); err != nil {
t.Fatal(err)
}
r.root.JSON = true
if err := r.printTimeline([]model.Note{note}); err != nil {
t.Fatal(err)
}
r.stdout = errWriter{}
r.root.JSON = false
if err := r.printPeople([]model.Person{person}); err == nil {
t.Fatal("expected printPeople write error")
}
if err := r.printTimeline([]model.Note{note}); err == nil {
t.Fatal("expected printTimeline write error")
}
if err := r.printHits([]model.SearchHit{hit}); err == nil {
t.Fatal("expected printHits write error")
}
if err := r.printPerson(person); err == nil {
t.Fatal("expected printPerson write error")
}
}
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}
func TestExecuteGitPushPullWithLocalRemote(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
data := filepath.Join(dir, "contacts")
remote := filepath.Join(dir, "remote.git")
if err := os.Mkdir(remote, 0o755); err != nil {
t.Fatal(err)
}
runShell(t, remote, "git", "init", "--bare")
var out, errOut bytes.Buffer
for _, args := range [][]string{
{"--config", cfg, "init", data, "--remote", remote},
{"--config", cfg, "person", "add", "Ada Remote"},
{"--config", cfg, "git", "commit", "-m", "test: remote"},
{"--config", cfg, "git", "push"},
{"--config", cfg, "git", "pull"},
} {
out.Reset()
errOut.Reset()
if err := Execute(args, &out, &errOut); err != nil {
t.Fatalf("%v: %v stderr=%s stdout=%s", args, err, errOut.String(), out.String())
}
}
}
func TestExecuteEditorExportPersonAndRepair(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := Execute([]string{"--config", cfg, "person", "add", "Ada Edit", "--email", "edit@example.com"}, &out, &errOut); err != nil {
t.Fatal(err)
}
editor := filepath.Join(t.TempDir(), "editor")
if err := os.WriteFile(editor, []byte("#!/bin/sh\nprintf '%s' \"$1\" > \""+filepath.Join(t.TempDir(), "edited")+"\"\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("EDITOR", editor)
if err := Execute([]string{"--config", cfg, "person", "edit", "edit@example.com"}, &out, &errOut); err != nil {
t.Fatal(err)
}
vcardPath := filepath.Join(t.TempDir(), "one.vcf")
if err := Execute([]string{"--config", cfg, "export", "vcard", "--person", "edit@example.com", "-o", vcardPath}, &out, &errOut); err != nil {
t.Fatal(err)
}
personPath := filepath.Join(data, "people", "ada-edit", "person.md")
if err := os.WriteFile(personPath, []byte("---\nid: person_x\nname: Ada Edit\ntags: [broken\n---\n# Ada Edit\n"), 0o600); err != nil {
t.Fatal(err)
}
out.Reset()
if err := Execute([]string{"--config", cfg, "--dry-run", "doctor", "--repair"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "repaired: 1") {
t.Fatalf("repair dry-run = %s", out.String())
}
out.Reset()
if err := Execute([]string{"--config", cfg, "doctor", "--repair"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "repaired: 1") {
t.Fatalf("repair = %s", out.String())
}
}
func TestExecuteUsageGuards(t *testing.T) {
cfg, data := testPaths(t)
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"--config", cfg, "note", "add", "nobody", "--kind", "note", "--source", "manual"},
{"--config", cfg, "note", "add", "nobody", "--kind", "note", "--source", "manual", "--text", "x", "--occurred-at", "bad"},
{"--config", cfg, "export", "vcard", "-o", filepath.Join(t.TempDir(), "x.vcf")},
{"--config", cfg, "person", "show", "missing"},
} {
out.Reset()
errOut.Reset()
if err := Execute(args, &out, &errOut); err == nil {
t.Fatalf("expected error for %v", args)
}
}
}
func TestSmallCLIHelpers(t *testing.T) {
if got := firstNonEmpty("", " ", "x"); got != "x" {
t.Fatalf("firstNonEmpty = %q", got)
}
if got := firstNonEmpty("", " "); got != "" {
t.Fatalf("firstNonEmpty empty = %q", got)
}
}
func TestExecuteErrorBranchesAndNoConfigInit(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
data := filepath.Join(dir, "contacts")
var out, errOut bytes.Buffer
if err := Execute([]string{"--config", cfg, "init", data, "--remote", "", "--no-config"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg); !os.IsNotExist(err) {
t.Fatalf("config unexpectedly exists: %v", err)
}
for _, args := range [][]string{
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "person", "add", "No Repo"},
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "person", "list"},
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "export", "vcard", "--all", "-o", "-"},
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "doctor"},
} {
out.Reset()
errOut.Reset()
if err := Execute(args, &out, &errOut); err == nil {
t.Fatalf("expected error for %v", args)
}
}
out.Reset()
errOut.Reset()
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := Execute([]string{"--config", cfg, "config", "set", "repo_path", data}, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := Execute([]string{"--config", cfg, "config", "set", "git.remote", ""}, &out, &errOut); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"--config", cfg, "note", "list", "missing"},
{"--config", cfg, "timeline", "missing"},
{"--config", cfg, "search", ""},
{"--config", cfg, "export", "vcard", "--person", "missing", "-o", "-"},
{"--config", cfg, "export", "vcard", "--all", "-o", filepath.Join(dir, "nope", "x.vcf")},
{"--config", cfg, "import", "apple", "--input", filepath.Join(dir, "missing.ndjson")},
} {
out.Reset()
errOut.Reset()
if err := Execute(args, &out, &errOut); err == nil {
t.Fatalf("expected error for %v", args)
}
}
fakeGog := writeFakeGogExit(t)
t.Setenv("PATH", filepath.Dir(fakeGog)+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := Execute([]string{"--config", cfg, "import", "google"}, &out, &errOut); err == nil {
t.Fatal("expected fake gog failure")
}
}
func testPaths(t *testing.T) (string, string) {
t.Helper()
dir := t.TempDir()
return filepath.Join(dir, "config.toml"), filepath.Join(dir, "contacts")
}
func writeFakeGog(t *testing.T, output string) string {
t.Helper()
dir := t.TempDir()
name := "gog"
if runtime.GOOS == "windows" {
name = "gog.bat"
}
path := filepath.Join(dir, name)
script := "#!/bin/sh\nprintf '%s\\n' '" + output + "'\n"
if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeFakeGogExit(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "gog")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho bad >&2\nexit 4\n"), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func writeFakeSQLite(t *testing.T, output string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "sqlite3")
if err := os.WriteFile(path, []byte("#!/bin/sh\ncat <<'JSON'\n"+output+"\nJSON\n"), 0o700); err != nil {
t.Fatal(err)
}
return path
}
func runShell(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.CommandContext(t.Context(), name, args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
}

View File

@ -0,0 +1,148 @@
package discrawl
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/openclaw/clawdex/internal/model"
)
type Adapter struct {
DBPath string
Binary string
}
type dmRow struct {
ChannelID string `json:"channel_id"`
Name string `json:"name"`
Messages int `json:"messages"`
FirstMessage string `json:"first_message"`
LastMessage string `json:"last_message"`
CounterpartID string `json:"counterpart_id"`
}
func (a Adapter) ListDMContacts(ctx context.Context, minMessages int) ([]model.SourceContact, error) {
if minMessages < 1 {
minMessages = 1
}
dbPath, err := resolveDBPath(a.DBPath)
if err != nil {
return nil, err
}
binary := strings.TrimSpace(a.Binary)
if binary == "" {
binary = "sqlite3"
}
query := fmt.Sprintf(dmQuery, minMessages)
// #nosec G204 -- sqlite3 is a configured binary and all arguments are passed without a shell.
cmd := exec.CommandContext(ctx, binary, "-json", "file:"+dbPath+"?mode=ro&immutable=1", query)
raw, err := cmd.Output()
if err != nil {
return nil, sqliteErr(err)
}
var rows []dmRow
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, nil
}
if err := json.Unmarshal(raw, &rows); err != nil {
return nil, err
}
out := make([]model.SourceContact, 0, len(rows))
for _, row := range rows {
name := strings.TrimSpace(row.Name)
if name == "" || row.ChannelID == "" {
continue
}
accounts := map[string][]string{"discord": {"channel:" + row.ChannelID}}
if row.CounterpartID != "" {
accounts["discord"] = append(accounts["discord"], "user:"+row.CounterpartID)
}
out = append(out, model.SourceContact{
Source: "discord",
ExternalID: row.ChannelID,
Name: name,
Tags: []string{"discord", "dm"},
Accounts: accounts,
})
}
return out, nil
}
func resolveDBPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(home, ".discrawl", "discrawl.db")
}
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
return filepath.Abs(path)
}
func sqliteErr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("discrawl sqlite query: %s", strings.TrimSpace(string(exitErr.Stderr)))
}
return err
}
const dmQuery = `
with dm as (
select
c.id as channel_id,
c.name as name,
count(m.id) as messages,
min(m.created_at) as first_message,
max(m.created_at) as last_message
from channels c
join messages m on m.channel_id = c.id
where c.guild_id = '@me' and c.kind = 'dm'
group by c.id, c.name
having count(m.id) > %d
),
self as (
select m.author_id
from channels c
join messages m on m.channel_id = c.id
where c.guild_id = '@me' and c.kind = 'dm' and m.author_id is not null
group by m.author_id
order by count(distinct c.id) desc, count(m.id) desc
limit 1
),
author_counts as (
select c.id as channel_id, m.author_id, count(m.id) as messages
from channels c
join messages m on m.channel_id = c.id
where c.guild_id = '@me' and c.kind = 'dm' and m.author_id is not null
group by c.id, m.author_id
)
select
dm.channel_id,
dm.name,
dm.messages,
dm.first_message,
dm.last_message,
coalesce((
select ac.author_id
from author_counts ac, self
where ac.channel_id = dm.channel_id and ac.author_id <> self.author_id
order by ac.messages desc
limit 1
), '') as counterpart_id
from dm
order by dm.messages desc, lower(dm.name);
`

View File

@ -0,0 +1,70 @@
package discrawl
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestListDMContactsViaSQLiteAdapter(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
script := `#!/bin/sh
case "$*" in
*'count(m.id) > 4'*) ;;
*) echo "missing threshold" >&2; exit 2 ;;
esac
cat <<'JSON'
[{"channel_id":"c1","name":"Ada DM","messages":5,"first_message":"2026-01-01T00:00:00Z","last_message":"2026-01-02T00:00:00Z","counterpart_id":"u1"}]
JSON
`
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
contacts, err := (Adapter{DBPath: "~/discrawl.db", Binary: bin}).ListDMContacts(t.Context(), 4)
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Name != "Ada DM" || contacts[0].Accounts["discord"][1] != "user:u1" {
t.Fatalf("contacts = %#v", contacts)
}
}
func TestListDMContactsErrors(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), 0); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("err = %v", err)
}
bad := filepath.Join(t.TempDir(), "sqlite3-bad")
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho not-json\n"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := (Adapter{Binary: bad}).ListDMContacts(t.Context(), 4); err == nil {
t.Fatal("expected json error")
}
}
func TestListDMContactsEmptyAndFilters(t *testing.T) {
bin := filepath.Join(t.TempDir(), "sqlite3")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[{\"channel_id\":\"\",\"name\":\"Skip\",\"messages\":8},{\"channel_id\":\"c2\",\"name\":\" \",\"messages\":8}]'\n"), 0o700); err != nil {
t.Fatal(err)
}
contacts, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), -1)
if err != nil {
t.Fatal(err)
}
if len(contacts) != 0 {
t.Fatalf("contacts = %#v", contacts)
}
empty := filepath.Join(t.TempDir(), "sqlite3-empty")
if err := os.WriteFile(empty, []byte("#!/bin/sh\nprintf ' '\n"), 0o700); err != nil {
t.Fatal(err)
}
contacts, err = (Adapter{Binary: empty}).ListDMContacts(t.Context(), 4)
if err != nil || len(contacts) != 0 {
t.Fatalf("contacts=%#v err=%v", contacts, err)
}
}

154
internal/google/gog.go Normal file
View File

@ -0,0 +1,154 @@
package google
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"github.com/openclaw/clawdex/internal/model"
)
type GogAdapter struct {
Binary string
}
func (g GogAdapter) ListContacts(ctx context.Context, account string) ([]model.SourceContact, error) {
binary := g.Binary
if binary == "" {
binary = "gog"
}
var out []model.SourceContact
page := ""
for {
args := []string{"--no-input", "contacts", "list", "--json", "--max", "1000"}
if page != "" {
args = append(args, "--page", page)
}
if strings.TrimSpace(account) != "" {
args = append([]string{"--account", account}, args...)
}
// #nosec G204 -- the adapter intentionally shells to a configured gog binary without using a shell.
cmd := exec.CommandContext(ctx, binary, args...)
raw, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return nil, fmt.Errorf("gog contacts list: %s", strings.TrimSpace(string(ee.Stderr)))
}
return nil, err
}
contacts, nextPage, err := parseGogContactsPage(raw)
if err != nil {
return nil, err
}
out = append(out, contacts...)
if nextPage == "" {
return out, nil
}
page = nextPage
}
}
type gogEnvelope struct {
Contacts []gogPerson `json:"contacts"`
Results []gogPerson `json:"results"`
People []gogPerson `json:"people"`
NextPageToken string `json:"nextPageToken"`
}
type gogPerson struct {
ResourceName string `json:"resourceName"`
Resource string `json:"resource"`
ETag string `json:"etag"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Emails []string `json:"emails"`
Phones []string `json:"phones"`
EmailAddresses []struct {
Value string `json:"value"`
Type string `json:"type"`
} `json:"emailAddresses"`
PhoneNumbers []struct {
Value string `json:"value"`
Type string `json:"type"`
} `json:"phoneNumbers"`
Names []struct {
DisplayName string `json:"displayName"`
GivenName string `json:"givenName"`
FamilyName string `json:"familyName"`
} `json:"names"`
}
func parseGogContacts(data []byte) ([]model.SourceContact, error) {
contacts, _, err := parseGogContactsPage(data)
return contacts, err
}
func parseGogContactsPage(data []byte) ([]model.SourceContact, string, error) {
var env gogEnvelope
if err := json.Unmarshal(data, &env); err == nil {
people := make([]gogPerson, 0, len(env.Contacts)+len(env.Results)+len(env.People))
people = append(people, env.Contacts...)
people = append(people, env.Results...)
people = append(people, env.People...)
if len(people) > 0 {
return convertPeople(people), env.NextPageToken, nil
}
}
var people []gogPerson
if err := json.Unmarshal(data, &people); err != nil {
return nil, "", err
}
return convertPeople(people), "", nil
}
func convertPeople(people []gogPerson) []model.SourceContact {
out := make([]model.SourceContact, 0, len(people))
for _, p := range people {
name := p.Name
if name == "" && len(p.Names) > 0 {
name = p.Names[0].DisplayName
if name == "" {
name = strings.TrimSpace(p.Names[0].GivenName + " " + p.Names[0].FamilyName)
}
}
c := model.SourceContact{Source: "google", ExternalID: firstNonEmpty(p.ResourceName, p.Resource), Name: name, ETag: p.ETag}
for i, email := range append(p.Emails, p.Email) {
if strings.TrimSpace(email) != "" {
c.Emails = append(c.Emails, model.ContactValue{Value: email, Source: "google", Primary: i == 0})
}
}
for _, email := range p.EmailAddresses {
if strings.TrimSpace(email.Value) != "" {
c.Emails = append(c.Emails, model.ContactValue{Value: email.Value, Label: email.Type, Source: "google"})
}
}
for i, phone := range append(p.Phones, p.Phone) {
if strings.TrimSpace(phone) != "" {
c.Phones = append(c.Phones, model.ContactValue{Value: phone, Source: "google", Primary: i == 0})
}
}
for _, phone := range p.PhoneNumbers {
if strings.TrimSpace(phone.Value) != "" {
c.Phones = append(c.Phones, model.ContactValue{Value: phone.Value, Label: phone.Type, Source: "google"})
}
}
if strings.TrimSpace(c.Name) != "" {
out = append(out, c)
}
}
return out
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -0,0 +1,87 @@
package google
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestParseGogContactsEnvelopeAndArray(t *testing.T) {
inputs := [][]byte{
[]byte(`{"contacts":[{"resourceName":"people/c1","etag":"e1","name":"Ada","email":"ada@example.com","phone":"+1 555 0100"}]}`),
[]byte(`[{"resource":"people/c1","names":[{"displayName":"Ada"}],"emailAddresses":[{"value":"ada@example.com","type":"home"}],"phoneNumbers":[{"value":"+1","type":"mobile"}]}]`),
}
for _, input := range inputs {
contacts, err := parseGogContacts(input)
if err != nil {
t.Fatal(err)
}
if len(contacts) != 1 || contacts[0].Source != "google" || contacts[0].Name != "Ada" {
t.Fatalf("contacts = %#v", contacts)
}
if contacts[0].ExternalID != "people/c1" || len(contacts[0].Emails) == 0 {
t.Fatalf("bad contact = %#v", contacts[0])
}
}
}
func TestGogAdapterListContactsUsesNoInput(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "gog")
if runtime.GOOS == "windows" {
bin += ".bat"
}
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"" + filepath.Join(dir, "args") + "\"\ncase \"$*\" in *next*) printf '%s\\n' '{\"contacts\":[{\"resourceName\":\"people/c2\",\"name\":\"Grace\"}]}' ;; *) printf '%s\\n' '{\"contacts\":[{\"resourceName\":\"people/c1\",\"name\":\"Ada\"}],\"nextPageToken\":\"next\"}' ;; esac\n"
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
contacts, err := (GogAdapter{Binary: bin}).ListContacts(t.Context(), "ada@example.com")
if err != nil {
t.Fatal(err)
}
if len(contacts) != 2 {
t.Fatalf("contacts = %#v", contacts)
}
args, err := os.ReadFile(filepath.Join(dir, "args"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(args), "--no-input") || !strings.Contains(string(args), "--account") {
t.Fatalf("args = %s", args)
}
if !strings.Contains(string(args), "--page") {
t.Fatalf("missing page args = %s", args)
}
}
func TestGogAdapterListContactsCommandFailure(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "gog")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho nope >&2\nexit 7\n"), 0o700); err != nil {
t.Fatal(err)
}
_, err := (GogAdapter{Binary: bin}).ListContacts(t.Context(), "")
if err == nil || !strings.Contains(err.Error(), "nope") {
t.Fatalf("err = %v", err)
}
if _, err := (GogAdapter{Binary: filepath.Join(dir, "missing")}).ListContacts(t.Context(), ""); err == nil {
t.Fatal("expected missing binary error")
}
}
func TestParseGogContactsRejectsInvalidJSON(t *testing.T) {
if _, err := parseGogContacts([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "unexpected") {
t.Fatalf("err = %v", err)
}
var p gogPerson
if err := json.Unmarshal([]byte(`{"names":[{"givenName":"Ada","familyName":"Lovelace"}]}`), &p); err != nil {
t.Fatal(err)
}
got := convertPeople([]gogPerson{p})
if len(got) != 1 || got[0].Name != "Ada Lovelace" {
t.Fatalf("got = %#v", got)
}
}

289
internal/index/import.go Normal file
View File

@ -0,0 +1,289 @@
package index
import (
"path/filepath"
"sort"
"strings"
"time"
"github.com/openclaw/clawdex/internal/markdown"
"github.com/openclaw/clawdex/internal/model"
)
func (s Store) ImportContacts(source string, contacts []model.SourceContact, dryRun bool, now time.Time) ([]model.ImportChange, error) {
people, err := s.People()
if err != nil {
return nil, err
}
var changes []model.ImportChange
for _, contact := range contacts {
contact.Source = source
if strings.TrimSpace(contact.Name) == "" {
continue
}
idx := matchContact(people, contact)
if idx < 0 {
p := markdown.NewPerson(contact.Name, now)
p.Tags = cleanList(contact.Tags)
p.Emails = sourceValues(contact.Emails, source)
p.Phones = sourceValues(contact.Phones, source)
p.Accounts = cleanAccounts(contact.Accounts)
setExternal(&p, source, contact, now)
change := model.ImportChange{Action: "create", PersonID: p.ID, Name: p.Name, Source: contact}
if !dryRun {
created, err := s.createImportedPerson(p)
if err != nil {
return nil, err
}
change.PersonID = created.ID
change.Path = created.Path
people = append(people, created)
}
changes = append(changes, change)
continue
}
p := people[idx]
beforeEmails := len(p.Emails)
beforePhones := len(p.Phones)
beforeTags := append([]string(nil), p.Tags...)
beforeAccounts := cloneAccounts(p.Accounts)
beforeApple := p.Apple
beforeGoogle := p.Google
p.Tags = appendMissingStrings(p.Tags, contact.Tags)
p.Emails = appendMissingValues(p.Emails, contact.Emails, source)
p.Phones = appendMissingValues(p.Phones, contact.Phones, source)
p.Accounts = mergeAccounts(p.Accounts, contact.Accounts)
setExternal(&p, source, contact, now)
externalChanged := p.Apple != beforeApple || p.Google != beforeGoogle
tagsChanged := strings.Join(beforeTags, "\x00") != strings.Join(p.Tags, "\x00")
accountsChanged := !accountsEqual(beforeAccounts, p.Accounts)
if len(p.Emails) == beforeEmails && len(p.Phones) == beforePhones && !tagsChanged && !accountsChanged && !externalChanged {
continue
}
change := model.ImportChange{Action: "update", PersonID: p.ID, Name: p.Name, Source: contact, Path: p.Path}
if !dryRun {
p.UpdatedAt = now.UTC()
if err := markdown.WritePerson(p.Path, p); err != nil {
return nil, err
}
people[idx] = p
}
changes = append(changes, change)
}
if !dryRun {
return changes, s.Rebuild()
}
return changes, nil
}
func matchContact(people []model.Person, contact model.SourceContact) int {
for i, p := range people {
if accountsOverlap(p.Accounts, contact.Accounts) {
return i
}
}
for i, p := range people {
switch contact.Source {
case "apple":
if contact.ExternalID != "" && p.Apple.ID == contact.ExternalID {
return i
}
case "google":
if contact.ExternalID != "" && p.Google.Resource == contact.ExternalID {
return i
}
}
}
for i, p := range people {
for _, email := range contact.Emails {
if model.NormalizeEmail(email.Value) != "" && personHasEmail(p, model.NormalizeEmail(email.Value)) {
return i
}
}
}
for i, p := range people {
for _, phone := range contact.Phones {
if model.NormalizePhone(phone.Value) != "" && personHasPhone(p, model.NormalizePhone(phone.Value)) {
return i
}
}
}
for i, p := range people {
if model.NormalizeName(p.Name) != "" && model.NormalizeName(p.Name) == model.NormalizeName(contact.Name) {
return i
}
}
return -1
}
func sourceValues(values []model.ContactValue, source string) []model.ContactValue {
out := make([]model.ContactValue, 0, len(values))
for i, value := range values {
value.Source = source
if value.Label == "" {
value.Label = "other"
}
if i == 0 {
value.Primary = true
}
out = append(out, value)
}
return out
}
func cleanAccounts(accounts map[string][]string) map[string][]string {
if len(accounts) == 0 {
return nil
}
out := map[string][]string{}
for service, values := range accounts {
service = strings.TrimSpace(strings.ToLower(service))
if service == "" {
continue
}
cleaned := cleanList(values)
if len(cleaned) > 0 {
out[service] = cleaned
}
}
if len(out) == 0 {
return nil
}
return out
}
func cloneAccounts(accounts map[string][]string) map[string][]string {
if len(accounts) == 0 {
return nil
}
out := make(map[string][]string, len(accounts))
for service, values := range accounts {
out[service] = append([]string(nil), values...)
}
return out
}
func mergeAccounts(existing map[string][]string, incoming map[string][]string) map[string][]string {
if len(incoming) == 0 {
return existing
}
if existing == nil {
existing = map[string][]string{}
}
for service, values := range cleanAccounts(incoming) {
existing[service] = appendMissingStrings(existing[service], values)
}
return existing
}
func appendMissingStrings(existing []string, incoming []string) []string {
seen := map[string]bool{}
for _, value := range existing {
seen[strings.ToLower(strings.TrimSpace(value))] = true
}
for _, value := range incoming {
value = strings.TrimSpace(value)
key := strings.ToLower(value)
if value == "" || seen[key] {
continue
}
existing = append(existing, value)
seen[key] = true
}
sort.Strings(existing)
return existing
}
func accountsOverlap(existing map[string][]string, incoming map[string][]string) bool {
for service, values := range cleanAccounts(incoming) {
current := existing[service]
for _, value := range values {
for _, cur := range current {
if strings.EqualFold(strings.TrimSpace(cur), strings.TrimSpace(value)) {
return true
}
}
}
}
return false
}
func accountsEqual(a, b map[string][]string) bool {
if len(a) != len(b) {
return false
}
for service, av := range a {
bv := b[service]
if len(av) != len(bv) {
return false
}
for i := range av {
if av[i] != bv[i] {
return false
}
}
}
return true
}
func appendMissingValues(existing []model.ContactValue, incoming []model.ContactValue, source string) []model.ContactValue {
for _, value := range incoming {
key := model.NormalizeEmail(value.Value)
if key == "" {
key = model.NormalizePhone(value.Value)
}
if key == "" {
continue
}
found := false
for _, cur := range existing {
curKey := model.NormalizeEmail(cur.Value)
if curKey == "" {
curKey = model.NormalizePhone(cur.Value)
}
if curKey == key {
found = true
break
}
}
if !found {
value.Source = source
if value.Label == "" {
value.Label = "other"
}
existing = append(existing, value)
}
}
return existing
}
func setExternal(p *model.Person, source string, contact model.SourceContact, now time.Time) {
switch source {
case "apple":
if contact.ExternalID == "" {
return
}
p.Apple.ID = contact.ExternalID
p.Apple.LastSeenAt = now.UTC()
case "google":
if contact.ExternalID == "" && contact.ETag == "" {
return
}
p.Google.Resource = contact.ExternalID
p.Google.ETag = contact.ETag
p.Google.LastSeenAt = now.UTC()
}
}
func (s Store) createImportedPerson(p model.Person) (model.Person, error) {
dir, err := s.uniquePersonDir(model.Slug(p.Name))
if err != nil {
return model.Person{}, err
}
p.Path = filepath.Join(dir, "person.md")
p.Body = "# " + p.Name + "\n"
if err := markdown.WritePerson(p.Path, p); err != nil {
return model.Person{}, err
}
return p, nil
}

345
internal/index/store.go Normal file
View File

@ -0,0 +1,345 @@
package index
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/openclaw/clawdex/internal/markdown"
"github.com/openclaw/clawdex/internal/model"
"github.com/openclaw/clawdex/internal/repo"
)
type Store struct {
Repo repo.Repo
}
func New(r repo.Repo) Store {
return Store{Repo: r}
}
func (s Store) AddPerson(name string, emails, phones, tags []string, now time.Time) (model.Person, error) {
p := markdown.NewPerson(name, now)
p.Tags = cleanList(tags)
for i, email := range cleanList(emails) {
p.Emails = append(p.Emails, model.ContactValue{Value: email, Label: "other", Source: "manual", Primary: i == 0})
}
for i, phone := range cleanList(phones) {
p.Phones = append(p.Phones, model.ContactValue{Value: phone, Label: "other", Source: "manual", Primary: i == 0})
}
dir, err := s.uniquePersonDir(model.Slug(name))
if err != nil {
return model.Person{}, err
}
path := filepath.Join(dir, "person.md")
p.Path = path
p.Body = "# " + p.Name + "\n"
if err := markdown.WritePerson(path, p); err != nil {
return model.Person{}, err
}
return p, s.Rebuild()
}
func (s Store) People() ([]model.Person, error) {
if err := s.Repo.Require(); err != nil {
return nil, err
}
entries, err := os.ReadDir(s.Repo.PeopleDir())
if err != nil {
return nil, err
}
people := make([]model.Person, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(s.Repo.PeopleDir(), entry.Name(), "person.md")
if _, err := os.Stat(path); err != nil {
continue
}
p, report, err := markdown.ReadPerson(path)
if err != nil {
return nil, err
}
if report.Needed && s.Repo.Config.Repair.AutoRepair {
if err := markdown.RepairPerson(path, s.Repo.RepairDir(), p, report, s.Repo.Config.Repair.BackupBeforeRepair); err != nil {
return nil, err
}
}
people = append(people, p)
}
sort.Slice(people, func(i, j int) bool {
return strings.ToLower(people[i].Name) < strings.ToLower(people[j].Name)
})
return people, nil
}
func (s Store) FindPerson(query string) (model.Person, error) {
query = strings.TrimSpace(query)
if query == "" {
return model.Person{}, errors.New("person query is required")
}
people, err := s.People()
if err != nil {
return model.Person{}, err
}
var matches []model.Person
nq := model.NormalizeName(query)
eq := model.NormalizeEmail(query)
pq := model.NormalizePhone(query)
for _, p := range people {
switch {
case p.ID == query:
return p, nil
case model.Slug(p.Name) == model.Slug(query):
matches = append(matches, p)
case strings.Contains(model.NormalizeName(p.Name), nq):
matches = append(matches, p)
case personHasEmail(p, eq):
matches = append(matches, p)
case pq != "" && personHasPhone(p, pq):
matches = append(matches, p)
}
}
if len(matches) == 0 {
return model.Person{}, fmt.Errorf("no person matched %q", query)
}
if len(matches) > 1 {
names := make([]string, 0, len(matches))
for _, match := range matches {
names = append(names, match.Name+" ("+match.ID+")")
}
return model.Person{}, fmt.Errorf("ambiguous person %q: %s", query, strings.Join(names, ", "))
}
return matches[0], nil
}
func (s Store) AddNote(personQuery string, note model.Note) (model.Note, error) {
p, err := s.FindPerson(personQuery)
if err != nil {
return model.Note{}, err
}
note.PersonID = p.ID
dir := filepath.Join(filepath.Dir(p.Path), "notes")
path := filepath.Join(dir, markdown.NoteFileName(note))
for i := 2; ; i++ {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
break
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
path = fmt.Sprintf("%s-%d%s", base, i, ext)
}
note.Path = path
if err := markdown.WriteNote(path, note); err != nil {
return model.Note{}, err
}
return note, nil
}
func (s Store) Notes(personQuery string) ([]model.Note, error) {
p, err := s.FindPerson(personQuery)
if err != nil {
return nil, err
}
return s.notesForPerson(p)
}
func (s Store) notesForPerson(p model.Person) ([]model.Note, error) {
dir := filepath.Join(filepath.Dir(p.Path), "notes")
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
notes := make([]model.Note, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
continue
}
n, _, err := markdown.ReadNote(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, err
}
if n.PersonID == "" {
n.PersonID = p.ID
}
notes = append(notes, n)
}
sort.Slice(notes, func(i, j int) bool {
return notes[i].OccurredAt.Before(notes[j].OccurredAt)
})
return notes, nil
}
func (s Store) Search(query string) ([]model.SearchHit, error) {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return nil, errors.New("search query is required")
}
people, err := s.People()
if err != nil {
return nil, err
}
var hits []model.SearchHit
for _, p := range people {
text := personSearchText(p)
if score := scoreText(text, query); score > 0 {
hits = append(hits, model.SearchHit{Kind: "person", ID: p.ID, Name: p.Name, Path: p.Path, Score: score, Snippet: p.Name})
}
notes, err := s.notesForPerson(p)
if err != nil {
return nil, err
}
for _, n := range notes {
text := strings.ToLower(strings.Join(append([]string{n.Kind, n.Source, n.Body}, n.Topics...), " "))
if score := scoreText(text, query); score > 0 {
hits = append(hits, model.SearchHit{Kind: "note", ID: n.ID, PersonID: p.ID, Name: p.Name, Path: n.Path, Score: score, Snippet: snippet(n.Body, query), Timestamp: n.OccurredAt})
}
}
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].Score == hits[j].Score {
return hits[i].Path < hits[j].Path
}
return hits[i].Score > hits[j].Score
})
return hits, nil
}
func (s Store) Rebuild() error {
people, err := s.People()
if err != nil {
return err
}
if err := os.MkdirAll(s.Repo.IndexDir(), 0o755); err != nil {
return err
}
emails := map[string]string{}
phones := map[string]string{}
handles := map[string]string{}
for _, p := range people {
for _, email := range p.Emails {
if v := model.NormalizeEmail(email.Value); v != "" {
emails[v] = p.ID
}
}
for _, phone := range p.Phones {
if v := model.NormalizePhone(phone.Value); v != "" {
phones[v] = p.ID
}
}
for service, values := range p.Accounts {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
handles[strings.ToLower(service+":"+value)] = p.ID
}
}
}
}
for name, value := range map[string]map[string]string{
"emails.json": emails,
"phones.json": phones,
"handles.json": handles,
} {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(s.Repo.IndexDir(), name), append(data, '\n'), 0o600); err != nil {
return err
}
}
return nil
}
func (s Store) uniquePersonDir(slug string) (string, error) {
if slug == "" {
slug = "person"
}
for i := 0; ; i++ {
candidate := slug
if i > 0 {
candidate = fmt.Sprintf("%s-%d", slug, i+1)
}
path := filepath.Join(s.Repo.PeopleDir(), candidate)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return path, nil
}
}
}
func cleanList(values []string) []string {
var out []string
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func personHasEmail(p model.Person, email string) bool {
for _, v := range p.Emails {
if model.NormalizeEmail(v.Value) == email {
return true
}
}
return false
}
func personHasPhone(p model.Person, phone string) bool {
for _, v := range p.Phones {
if model.NormalizePhone(v.Value) == phone {
return true
}
}
return false
}
func personSearchText(p model.Person) string {
parts := []string{p.ID, p.Name, p.SortName, p.Body}
parts = append(parts, p.Tags...)
for _, email := range p.Emails {
parts = append(parts, email.Value)
}
for _, phone := range p.Phones {
parts = append(parts, phone.Value)
}
for service, values := range p.Accounts {
parts = append(parts, service)
parts = append(parts, values...)
}
return strings.ToLower(strings.Join(parts, " "))
}
func scoreText(text, query string) int {
if text == query {
return 100
}
return strings.Count(text, query)
}
func snippet(body, query string) string {
lower := strings.ToLower(body)
idx := strings.Index(lower, query)
if idx < 0 {
return ""
}
start := idx - 40
start = max(start, 0)
end := idx + len(query) + 80
end = min(end, len(body))
return strings.TrimSpace(body[start:end])
}

View File

@ -0,0 +1,364 @@
package index
import (
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/openclaw/clawdex/internal/markdown"
"github.com/openclaw/clawdex/internal/model"
"github.com/openclaw/clawdex/internal/repo"
)
func TestAddNoteAndSearch(t *testing.T) {
r := testRepo(t)
s := New(r)
p, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now())
if err != nil {
t.Fatal(err)
}
n := markdown.NewNote(p.ID, "dm", "manual", "Analytical engine follow-up", time.Time{}, time.Now(), []string{"math"})
if _, err := s.AddNote("ada@example.com", n); err != nil {
t.Fatal(err)
}
hits, err := s.Search("engine")
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Kind != "note" {
t.Fatalf("hits = %#v", hits)
}
}
func TestFindPersonVariantsAndErrors(t *testing.T) {
r := testRepo(t)
s := New(r)
p, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, []string{"+1 555 0100"}, nil, time.Now())
if err != nil {
t.Fatal(err)
}
if _, err := s.AddPerson("Ada Lovelace", []string{"ada2@example.com"}, nil, nil, time.Now()); err != nil {
t.Fatal(err)
}
for _, query := range []string{p.ID, "ada@example.com", "15550100"} {
got, err := s.FindPerson(query)
if err != nil || got.ID != p.ID {
t.Fatalf("query %q got=%#v err=%v", query, got, err)
}
}
if _, err := s.FindPerson("ada"); err == nil {
t.Fatal("expected ambiguous name")
}
if _, err := s.FindPerson("missing"); err == nil {
t.Fatal("expected missing")
}
if _, err := s.FindPerson(""); err == nil {
t.Fatal("expected empty query error")
}
}
func TestNotesMissingDirAndDuplicateNames(t *testing.T) {
r := testRepo(t)
s := New(r)
if _, err := s.AddPerson("Ada Lovelace", nil, nil, nil, time.Now()); err != nil {
t.Fatal(err)
}
notes, err := s.Notes("Ada Lovelace")
if err != nil {
t.Fatal(err)
}
if len(notes) != 0 {
t.Fatalf("notes = %#v", notes)
}
now := time.Date(2026, 5, 8, 9, 0, 0, 0, time.UTC)
n := markdown.NewNote("", "dm", "manual", "first", now, now, nil)
if _, err := s.AddNote("Ada Lovelace", n); err != nil {
t.Fatal(err)
}
n.Body = "second"
if _, err := s.AddNote("Ada Lovelace", n); err != nil {
t.Fatal(err)
}
notes, err = s.Notes("Ada Lovelace")
if err != nil {
t.Fatal(err)
}
if len(notes) != 2 || notes[0].Body == notes[1].Body {
t.Fatalf("notes = %#v", notes)
}
}
func TestImportMatchesEmail(t *testing.T) {
r := testRepo(t)
s := New(r)
if _, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now()); err != nil {
t.Fatal(err)
}
changes, err := s.ImportContacts("google", []model.SourceContact{{
Source: "google",
Name: "Ada Lovelace",
Emails: []model.ContactValue{{Value: "ADA@example.com"}},
Phones: []model.ContactValue{{Value: "+1 555 0100"}},
}}, false, time.Now())
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("changes = %#v", changes)
}
p, err := s.FindPerson("ada@example.com")
if err != nil {
t.Fatal(err)
}
if len(p.Phones) != 1 {
t.Fatalf("phones = %#v", p.Phones)
}
}
func TestImportWritesExternalOnlyChange(t *testing.T) {
r := testRepo(t)
s := New(r)
if _, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now()); err != nil {
t.Fatal(err)
}
changes, err := s.ImportContacts("google", []model.SourceContact{{
ExternalID: "people/c1",
Name: "Ada Lovelace",
Emails: []model.ContactValue{{Value: "ada@example.com"}},
}}, false, time.Now())
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("changes = %#v", changes)
}
p, err := s.FindPerson("ada@example.com")
if err != nil {
t.Fatal(err)
}
if p.Google.Resource != "people/c1" {
t.Fatalf("google = %#v", p.Google)
}
}
func TestImportCreateDryRunAndExternalID(t *testing.T) {
r := testRepo(t)
s := New(r)
now := time.Now()
changes, err := s.ImportContacts("apple", []model.SourceContact{{
ExternalID: "a1",
Name: "Ada Apple",
Emails: []model.ContactValue{{Value: "apple@example.com"}},
}}, true, now)
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("changes = %#v", changes)
}
if _, err := s.FindPerson("apple@example.com"); err == nil {
t.Fatal("dry-run created person")
}
if _, err := s.ImportContacts("apple", []model.SourceContact{{
ExternalID: "a1",
Name: "Ada Apple",
Emails: []model.ContactValue{{Value: "apple@example.com"}},
}}, false, now); err != nil {
t.Fatal(err)
}
p, err := s.FindPerson("apple@example.com")
if err != nil {
t.Fatal(err)
}
if p.Apple.ID != "a1" {
t.Fatalf("apple ref = %#v", p.Apple)
}
changes, err = s.ImportContacts("apple", []model.SourceContact{{
ExternalID: "a1",
Name: "Ada Renamed",
Phones: []model.ContactValue{{Value: "+1 555 0101"}},
}}, false, now)
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("changes = %#v", changes)
}
}
func TestImportTagsAccountsAndExactNameMatch(t *testing.T) {
r := testRepo(t)
s := New(r)
now := time.Now()
changes, err := s.ImportContacts("discord", []model.SourceContact{{
ExternalID: "channel1",
Name: "Discord Person",
Tags: []string{"discord", "dm"},
Accounts: map[string][]string{"discord": {"channel:channel1", "user:user1"}},
}}, false, now)
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("changes = %#v", changes)
}
p, err := s.FindPerson("Discord Person")
if err != nil {
t.Fatal(err)
}
if len(p.Tags) != 2 || len(p.Accounts["discord"]) != 2 {
t.Fatalf("person = %#v", p)
}
changes, err = s.ImportContacts("discord", []model.SourceContact{{
Name: "Discord Person",
Tags: []string{"discord", "dm", "friend"},
Accounts: map[string][]string{"discord": {"channel:channel1"}},
}}, false, now)
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("changes = %#v", changes)
}
p, err = s.FindPerson("Discord Person")
if err != nil {
t.Fatal(err)
}
if !stringIn(p.Tags, "friend") {
t.Fatalf("tags = %#v", p.Tags)
}
}
func TestSearchPersonAndEmptyQuery(t *testing.T) {
r := testRepo(t)
s := New(r)
if _, err := s.AddPerson("Ada Lovelace", nil, nil, []string{"math"}, time.Now()); err != nil {
t.Fatal(err)
}
hits, err := s.Search("math")
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Kind != "person" {
t.Fatalf("hits = %#v", hits)
}
if _, err := s.Search(""); err == nil {
t.Fatal("expected empty search error")
}
}
func TestSearchAccountsAndBadNoteError(t *testing.T) {
r := testRepo(t)
s := New(r)
p, err := s.AddPerson("Handle Person", nil, nil, nil, time.Now())
if err != nil {
t.Fatal(err)
}
p.Accounts = map[string][]string{"github": {"handle-person"}}
if err := markdown.WritePerson(p.Path, p); err != nil {
t.Fatal(err)
}
hits, err := s.Search("handle-person")
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Kind != "person" {
t.Fatalf("hits = %#v", hits)
}
notesDir := filepath.Join(filepath.Dir(p.Path), "notes")
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(notesDir, "missing"), filepath.Join(notesDir, "bad.md")); err != nil {
t.Fatal(err)
}
if _, err := s.Search("handle-person"); err == nil {
t.Fatal("expected bad note read error")
}
}
func TestSmallHelpers(t *testing.T) {
if got := cleanList([]string{"a", "a", "", " b "}); len(got) != 2 || got[1] != "b" {
t.Fatalf("clean = %#v", got)
}
if scoreText("abc", "abc") != 100 || scoreText("abc xyz", "abc") != 1 || scoreText("abc", "z") != 0 {
t.Fatal("bad scores")
}
if got := snippet("short body", "missing"); got != "" {
t.Fatalf("snippet = %q", got)
}
}
func TestPeopleAutoRepairRebuildAccountsAndImportNoop(t *testing.T) {
r := testRepo(t)
s := New(r)
p, err := s.AddPerson("Ada Accounts", []string{"acct@example.com"}, nil, nil, time.Now())
if err != nil {
t.Fatal(err)
}
p.Accounts = map[string][]string{"github": {"ada"}}
if err := markdown.WritePerson(p.Path, p); err != nil {
t.Fatal(err)
}
if err := s.Rebuild(); err != nil {
t.Fatal(err)
}
people, err := s.People()
if err != nil {
t.Fatal(err)
}
if len(people) != 1 {
t.Fatalf("people = %#v", people)
}
changes, err := s.ImportContacts("google", []model.SourceContact{{Name: ""}, {Name: "Ada Accounts", Emails: []model.ContactValue{{Value: "acct@example.com"}}}}, false, time.Now())
if err != nil {
t.Fatal(err)
}
if len(changes) != 0 {
t.Fatalf("changes = %#v", changes)
}
}
func TestImportMatchesPhoneAndGoogleExternal(t *testing.T) {
r := testRepo(t)
s := New(r)
if _, err := s.ImportContacts("google", []model.SourceContact{{ExternalID: "people/c1", Name: "Ada Google", Phones: []model.ContactValue{{Value: "+1 555 0100"}}}}, false, time.Now()); err != nil {
t.Fatal(err)
}
changes, err := s.ImportContacts("google", []model.SourceContact{{ExternalID: "people/c1", Name: "Ada Google", Emails: []model.ContactValue{{Value: "g@example.com"}}}}, false, time.Now())
if err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("changes = %#v", changes)
}
p, err := s.FindPerson("+1 555 0100")
if err != nil {
t.Fatal(err)
}
if p.Google.Resource != "people/c1" {
t.Fatalf("google = %#v", p.Google)
}
}
func testRepo(t *testing.T) repo.Repo {
t.Helper()
dir := t.TempDir()
cfg := repo.DefaultConfig()
cfg.RepoPath = dir
cfg.Git.Remote = ""
r := repo.Open(dir, cfg)
if err := r.Init(t.Context()); err != nil {
t.Fatal(err)
}
if got := filepath.Base(r.PeopleDir()); got != "people" {
t.Fatalf("bad people dir: %s", r.PeopleDir())
}
return r
}
func stringIn(values []string, want string) bool {
return slices.Contains(values, want)
}

View File

@ -0,0 +1,451 @@
package markdown
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/openclaw/clawdex/internal/model"
"gopkg.in/yaml.v3"
)
type RepairReport struct {
Path string `json:"path"`
Needed bool `json:"needed"`
Problems []string `json:"problems,omitempty"`
RecoveredMetadata string `json:"recovered_metadata,omitempty"`
}
func NewPerson(name string, now time.Time) model.Person {
return model.Person{
ID: "person_" + uuid.NewString(),
Name: strings.TrimSpace(name),
CreatedAt: now.UTC(),
UpdatedAt: now.UTC(),
}
}
func NewNote(personID, kind, source, body string, occurredAt, now time.Time, topics []string) model.Note {
if occurredAt.IsZero() {
occurredAt = now
}
return model.Note{
ID: "note_" + uuid.NewString(),
PersonID: personID,
OccurredAt: occurredAt.UTC(),
CapturedAt: now.UTC(),
Kind: strings.TrimSpace(kind),
Source: strings.TrimSpace(source),
Confidence: "high",
Privacy: "normal",
Topics: topics,
Body: body,
}
}
func ReadPerson(path string) (model.Person, RepairReport, error) {
data, err := os.ReadFile(path)
if err != nil {
return model.Person{}, RepairReport{}, err
}
front, body, ok := splitFrontmatter(data)
report := RepairReport{Path: path}
var p model.Person
if ok {
if err := yaml.Unmarshal([]byte(front), &p); err != nil {
report.Needed = true
report.Problems = append(report.Problems, "invalid YAML frontmatter: "+err.Error())
report.RecoveredMetadata = front
p = salvagePerson(front)
}
} else {
report.Needed = true
report.Problems = append(report.Problems, "missing YAML frontmatter")
body = string(data)
}
p.Body = strings.TrimLeft(body, "\n")
p.Path = path
inferPerson(&p, path)
return p, report, nil
}
func WritePerson(path string, p model.Person) error {
inferPerson(&p, path)
p.UpdatedAt = p.UpdatedAt.UTC()
front, err := yaml.Marshal(personFrontmatter(p))
if err != nil {
return err
}
body := strings.TrimLeft(p.Body, "\n")
if body == "" {
body = "# " + p.Name + "\n"
}
return atomicWrite(path, appendFrontmatter(front, body), 0o600)
}
func RepairPerson(path, repairRoot string, p model.Person, report RepairReport, backup bool) error {
if !report.Needed {
return nil
}
if backup {
if err := backupOriginal(path, repairRoot); err != nil {
return err
}
}
if report.RecoveredMetadata != "" && !strings.Contains(p.Body, "## Recovered metadata") {
p.Body = strings.TrimRight(p.Body, "\n") + "\n\n## Recovered metadata\n\n```yaml\n" + strings.TrimSpace(report.RecoveredMetadata) + "\n```\n"
}
return WritePerson(path, p)
}
func ReadNote(path string) (model.Note, RepairReport, error) {
data, err := os.ReadFile(path)
if err != nil {
return model.Note{}, RepairReport{}, err
}
front, body, ok := splitFrontmatter(data)
report := RepairReport{Path: path}
var n model.Note
if ok {
if err := yaml.Unmarshal([]byte(front), &n); err != nil {
report.Needed = true
report.Problems = append(report.Problems, "invalid YAML frontmatter: "+err.Error())
report.RecoveredMetadata = front
n = salvageNote(front)
}
} else {
report.Needed = true
report.Problems = append(report.Problems, "missing YAML frontmatter")
body = string(data)
}
n.Body = strings.TrimLeft(body, "\n")
n.Path = path
inferNote(&n, path)
return n, report, nil
}
func WriteNote(path string, n model.Note) error {
inferNote(&n, path)
front, err := yaml.Marshal(noteFrontmatter(n))
if err != nil {
return err
}
return atomicWrite(path, appendFrontmatter(front, strings.TrimLeft(n.Body, "\n")), 0o600)
}
func splitFrontmatter(data []byte) (string, string, bool) {
text := string(data)
if !strings.HasPrefix(text, "---\n") && !strings.HasPrefix(text, "---\r\n") {
return "", text, false
}
normalized := strings.ReplaceAll(text, "\r\n", "\n")
rest := normalized[4:]
front, body, ok := strings.Cut(rest, "\n---\n")
if !ok {
if front, ok := strings.CutSuffix(rest, "\n---"); ok {
return front, "", true
}
return "", text, false
}
return front, body, true
}
func appendFrontmatter(front []byte, body string) []byte {
var buf bytes.Buffer
buf.WriteString("---\n")
buf.Write(bytes.TrimSpace(front))
buf.WriteString("\n---\n")
buf.WriteString(strings.TrimLeft(body, "\n"))
if !strings.HasSuffix(buf.String(), "\n") {
buf.WriteByte('\n')
}
return buf.Bytes()
}
func inferPerson(p *model.Person, path string) {
if p.ID == "" {
p.ID = "person_" + uuid.NewString()
}
if strings.TrimSpace(p.Name) == "" {
p.Name = nameFromBody(p.Body)
}
if strings.TrimSpace(p.Name) == "" {
p.Name = strings.ReplaceAll(model.PathSlug(path), "-", " ")
}
if p.CreatedAt.IsZero() {
p.CreatedAt = fileTime(path)
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = fileTime(path)
}
if p.Accounts == nil {
p.Accounts = map[string][]string{}
}
}
func inferNote(n *model.Note, path string) {
if n.ID == "" {
n.ID = "note_" + uuid.NewString()
}
if n.OccurredAt.IsZero() {
n.OccurredAt = fileTime(path)
}
if n.CapturedAt.IsZero() {
n.CapturedAt = fileTime(path)
}
if n.Kind == "" {
n.Kind = "note"
}
if n.Source == "" {
n.Source = "manual"
}
if n.Confidence == "" {
n.Confidence = "medium"
}
if n.Privacy == "" {
n.Privacy = "normal"
}
}
type personFront struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
SortName string `yaml:"sort_name,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Emails []model.ContactValue `yaml:"emails,omitempty"`
Phones []model.ContactValue `yaml:"phones,omitempty"`
Accounts map[string][]string `yaml:"accounts,omitempty"`
Apple *model.ExternalRef `yaml:"apple,omitempty"`
Google *model.ExternalRef `yaml:"google,omitempty"`
CreatedAt time.Time `yaml:"created_at"`
UpdatedAt time.Time `yaml:"updated_at"`
}
func personFrontmatter(p model.Person) personFront {
return personFront{
ID: p.ID,
Name: p.Name,
SortName: p.SortName,
Tags: p.Tags,
Emails: p.Emails,
Phones: p.Phones,
Accounts: nonEmptyAccounts(p.Accounts),
Apple: nonEmptyExternal(p.Apple),
Google: nonEmptyExternal(p.Google),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
type noteFront struct {
ID string `yaml:"id"`
PersonID string `yaml:"person_id"`
OccurredAt time.Time `yaml:"occurred_at"`
CapturedAt time.Time `yaml:"captured_at"`
Kind string `yaml:"kind"`
Source string `yaml:"source"`
Account string `yaml:"account,omitempty"`
ExternalID string `yaml:"external_id,omitempty"`
Direction string `yaml:"direction,omitempty"`
Confidence string `yaml:"confidence,omitempty"`
Topics []string `yaml:"topics,omitempty"`
FollowUpAt *time.Time `yaml:"follow_up_at,omitempty"`
Privacy string `yaml:"privacy,omitempty"`
}
func noteFrontmatter(n model.Note) noteFront {
return noteFront{
ID: n.ID,
PersonID: n.PersonID,
OccurredAt: n.OccurredAt,
CapturedAt: n.CapturedAt,
Kind: n.Kind,
Source: n.Source,
Account: n.Account,
ExternalID: n.ExternalID,
Direction: n.Direction,
Confidence: n.Confidence,
Topics: n.Topics,
FollowUpAt: nonZeroTime(n.FollowUpAt),
Privacy: n.Privacy,
}
}
func nonEmptyExternal(ref model.ExternalRef) *model.ExternalRef {
if ref.ID == "" && ref.Resource == "" && ref.ETag == "" && ref.LastSeenAt.IsZero() {
return nil
}
return &ref
}
func nonEmptyAccounts(accounts map[string][]string) map[string][]string {
if len(accounts) == 0 {
return nil
}
return accounts
}
func nonZeroTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func nameFromBody(body string) string {
for line := range strings.SplitSeq(body, "\n") {
line = strings.TrimSpace(line)
if title, ok := strings.CutPrefix(line, "# "); ok {
return strings.TrimSpace(title)
}
}
return ""
}
func fileTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Now().UTC()
}
return info.ModTime().UTC()
}
func atomicWrite(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func backupOriginal(path, repairRoot string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel := strings.TrimPrefix(filepath.Clean(path), string(filepath.Separator))
dest := filepath.Join(repairRoot, time.Now().UTC().Format("20060102T150405Z"), rel)
if !strings.HasPrefix(dest, filepath.Clean(repairRoot)+string(filepath.Separator)) {
return fmt.Errorf("repair backup escaped repair root: %s", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
// #nosec G703 -- dest is constrained to repairRoot above.
return os.WriteFile(dest, data, 0o600)
}
func salvagePerson(front string) model.Person {
var p model.Person
values := salvageScalars(front)
p.ID = values["id"]
p.Name = values["name"]
p.SortName = values["sort_name"]
p.Tags = splitList(values["tags"])
p.CreatedAt = parseTime(values["created_at"])
p.UpdatedAt = parseTime(values["updated_at"])
return p
}
func salvageNote(front string) model.Note {
var n model.Note
values := salvageScalars(front)
n.ID = values["id"]
n.PersonID = values["person_id"]
n.Kind = values["kind"]
n.Source = values["source"]
n.Account = values["account"]
n.ExternalID = values["external_id"]
n.Direction = values["direction"]
n.Confidence = values["confidence"]
n.Privacy = values["privacy"]
n.Topics = splitList(values["topics"])
n.OccurredAt = parseTime(values["occurred_at"])
n.CapturedAt = parseTime(values["captured_at"])
n.FollowUpAt = parseTime(values["follow_up_at"])
return n
}
func salvageScalars(front string) map[string]string {
out := map[string]string{}
for line := range strings.SplitSeq(front, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
if key != "" {
out[key] = value
}
}
return out
}
func splitList(value string) []string {
value = strings.TrimSpace(strings.Trim(value, "[]"))
if value == "" {
return nil
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(strings.TrimSpace(part), `"'`)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseTime(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
t, err := time.Parse(layout, value)
if err == nil {
return t.UTC()
}
}
return time.Time{}
}
func NoteFileName(n model.Note) string {
t := n.OccurredAt.UTC()
if t.IsZero() {
t = time.Now().UTC()
}
kind := "note"
if strings.TrimSpace(n.Kind) != "" {
kind = model.Slug(n.Kind)
}
if kind == "" || kind == "person" {
kind = "note"
}
return fmt.Sprintf("%s-%s.md", t.Format("2006-01-02T15-04-05Z"), kind)
}

View File

@ -0,0 +1,204 @@
package markdown
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/openclaw/clawdex/internal/model"
)
func TestPersonRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "people", "ada", "person.md")
now := time.Date(2026, 5, 8, 9, 15, 0, 0, time.UTC)
p := NewPerson("Ada Lovelace", now)
p.Tags = []string{"math"}
p.Body = "# Ada Lovelace\n\nNotes."
if err := WritePerson(path, p); err != nil {
t.Fatal(err)
}
got, report, err := ReadPerson(path)
if err != nil {
t.Fatal(err)
}
if report.Needed {
t.Fatalf("unexpected repair report: %#v", report)
}
if got.ID != p.ID || got.Name != p.Name || strings.TrimSpace(got.Body) != strings.TrimSpace(p.Body) {
t.Fatalf("roundtrip mismatch: %#v", got)
}
}
func TestPersonRepairSalvagesBrokenFrontmatter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "people", "ada", "person.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
body := "---\nid: person_1\nname: Ada Lovelace\ntags: [math\n---\n# Ada\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
p, report, err := ReadPerson(path)
if err != nil {
t.Fatal(err)
}
if !report.Needed || p.ID != "person_1" || p.Name != "Ada Lovelace" {
t.Fatalf("repair = %#v person = %#v", report, p)
}
if err := RepairPerson(path, filepath.Join(dir, ".clawdex", "repairs"), p, report, true); err != nil {
t.Fatal(err)
}
repaired, _, err := ReadPerson(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(repaired.Body, "Recovered metadata") {
t.Fatalf("missing recovered metadata in body: %q", repaired.Body)
}
}
func TestReadPersonMissingFrontmatterInfersNameFromHeading(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "people", "ada", "person.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Ada Heading\n\nBody"), 0o600); err != nil {
t.Fatal(err)
}
p, report, err := ReadPerson(path)
if err != nil {
t.Fatal(err)
}
if !report.Needed || p.Name != "Ada Heading" || p.ID == "" {
t.Fatalf("person=%#v report=%#v", p, report)
}
}
func TestReadPersonMalformedDelimiterUsesSlug(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "people", "slug-name", "person.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("---\nname: Ada\n# Missing close"), 0o600); err != nil {
t.Fatal(err)
}
p, report, err := ReadPerson(path)
if err != nil {
t.Fatal(err)
}
if !report.Needed || p.Name != "Missing close" {
t.Fatalf("person=%#v report=%#v", p, report)
}
}
func TestNoteRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
now := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
n := NewNote("person_1", "dm", "whatsapp", "hello", now, now, []string{"intro"})
if err := WriteNote(path, n); err != nil {
t.Fatal(err)
}
got, report, err := ReadNote(path)
if err != nil {
t.Fatal(err)
}
if report.Needed || got.ID != n.ID || got.PersonID != "person_1" || strings.TrimSpace(got.Body) != "hello" {
t.Fatalf("got %#v report %#v", got, report)
}
}
func TestReadNoteRepairAndDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
body := "---\nid: note_1\nperson_id: person_1\noccurred_at: 2026-05-08\ncaptured_at: 2026-05-08T09:00:00Z\ntopics: [one, two\n---\nBody\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
n, report, err := ReadNote(path)
if err != nil {
t.Fatal(err)
}
if !report.Needed || n.ID != "note_1" || n.Kind != "note" || n.Source != "manual" || n.Privacy != "normal" {
t.Fatalf("note=%#v report=%#v", n, report)
}
}
func TestFrontmatterHelpers(t *testing.T) {
front, body, ok := splitFrontmatter([]byte("---\nid: x\n---"))
if !ok || strings.TrimSpace(front) != "id: x" || body != "" {
t.Fatalf("front=%q body=%q ok=%v", front, body, ok)
}
for _, value := range []string{"2026-05-08", "2026-05-08T09:00:00Z", "bad"} {
_ = parseTime(value)
}
n := NewNote("p", "", "", "", time.Time{}, time.Time{}, nil)
if n.Kind != "" || !n.OccurredAt.IsZero() {
t.Fatalf("new note zero = %#v", n)
}
if got := NoteFileName(n); !strings.HasSuffix(got, "-note.md") {
t.Fatalf("note file = %q", got)
}
}
func TestWritePersonOmitsEmptyStructsButKeepsNonEmpty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "person.md")
p := NewPerson("Ada", time.Now())
p.Accounts = map[string][]string{"github": {"ada"}}
p.Google.Resource = "people/c1"
if err := WritePerson(path, p); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "accounts:") || !strings.Contains(text, "google:") || strings.Contains(text, "apple:") {
t.Fatalf("frontmatter = %s", text)
}
}
func TestMoreRepairAndNoteBranches(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
if err := os.WriteFile(path, []byte("body only"), 0o600); err != nil {
t.Fatal(err)
}
n, report, err := ReadNote(path)
if err != nil {
t.Fatal(err)
}
if !report.Needed || n.Kind != "note" || strings.TrimSpace(n.Body) != "body only" {
t.Fatalf("note=%#v report=%#v", n, report)
}
now := time.Now().UTC()
n.FollowUpAt = now
if err := WriteNote(path, n); err != nil {
t.Fatal(err)
}
if err := RepairPerson(path, filepath.Join(dir, "repairs"), modelPersonForTest(), RepairReport{}, true); err != nil {
t.Fatal(err)
}
if nameFromBody("no heading") != "" {
t.Fatal("expected no heading")
}
parentFile := filepath.Join(dir, "file")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := atomicWrite(filepath.Join(parentFile, "child"), []byte("x"), 0o600); err == nil {
t.Fatal("expected atomic write mkdir error")
}
}
func modelPersonForTest() model.Person {
return NewPerson("Ada", time.Now())
}

33
internal/match/match.go Normal file
View File

@ -0,0 +1,33 @@
package match
import "github.com/openclaw/clawdex/internal/model"
type Candidate struct {
PersonID string `json:"person_id"`
Reason string `json:"reason"`
Score int `json:"score"`
}
func CandidateFor(contact model.SourceContact, p model.Person) (Candidate, bool) {
if contact.ExternalID != "" && (p.Apple.ID == contact.ExternalID || p.Google.Resource == contact.ExternalID) {
return Candidate{PersonID: p.ID, Reason: "external_id", Score: 100}, true
}
for _, email := range contact.Emails {
for _, existing := range p.Emails {
if model.NormalizeEmail(email.Value) != "" && model.NormalizeEmail(email.Value) == model.NormalizeEmail(existing.Value) {
return Candidate{PersonID: p.ID, Reason: "email", Score: 90}, true
}
}
}
for _, phone := range contact.Phones {
for _, existing := range p.Phones {
if model.NormalizePhone(phone.Value) != "" && model.NormalizePhone(phone.Value) == model.NormalizePhone(existing.Value) {
return Candidate{PersonID: p.ID, Reason: "phone", Score: 80}, true
}
}
}
if model.NormalizeName(contact.Name) != "" && model.NormalizeName(contact.Name) == model.NormalizeName(p.Name) {
return Candidate{PersonID: p.ID, Reason: "name", Score: 40}, true
}
return Candidate{}, false
}

View File

@ -0,0 +1,39 @@
package match
import (
"testing"
"github.com/openclaw/clawdex/internal/model"
)
func TestCandidateForPrefersEmail(t *testing.T) {
candidate, ok := CandidateFor(
model.SourceContact{Name: "Ada", Emails: []model.ContactValue{{Value: "ADA@example.com"}}},
model.Person{ID: "person_1", Name: "Different", Emails: []model.ContactValue{{Value: "ada@example.com"}}},
)
if !ok || candidate.Reason != "email" || candidate.Score != 90 {
t.Fatalf("candidate = %#v ok=%v", candidate, ok)
}
}
func TestCandidateForAllReasons(t *testing.T) {
cases := []struct {
name string
contact model.SourceContact
person model.Person
reason string
ok bool
}{
{"external", model.SourceContact{ExternalID: "a1"}, model.Person{ID: "p1", Apple: model.ExternalRef{ID: "a1"}}, "external_id", true},
{"google", model.SourceContact{ExternalID: "people/c1"}, model.Person{ID: "p1", Google: model.ExternalRef{Resource: "people/c1"}}, "external_id", true},
{"phone", model.SourceContact{Phones: []model.ContactValue{{Value: "+1 555"}}}, model.Person{ID: "p1", Phones: []model.ContactValue{{Value: "1555"}}}, "phone", true},
{"name", model.SourceContact{Name: "Ada Lovelace"}, model.Person{ID: "p1", Name: " Ada Lovelace "}, "name", true},
{"none", model.SourceContact{Name: "Ada"}, model.Person{ID: "p1", Name: "Grace"}, "", false},
}
for _, tt := range cases {
got, ok := CandidateFor(tt.contact, tt.person)
if ok != tt.ok || got.Reason != tt.reason {
t.Fatalf("%s: got %#v ok=%v", tt.name, got, ok)
}
}
}

View File

@ -0,0 +1,52 @@
package model
import (
"path/filepath"
"regexp"
"strings"
"unicode"
)
var slugDash = regexp.MustCompile(`-+`)
func Slug(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
var b strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
case unicode.IsSpace(r), r == '-', r == '_', r == '\'', r == '.':
b.WriteByte('-')
}
}
out := strings.Trim(slugDash.ReplaceAllString(b.String(), "-"), "-")
if out == "" {
return "person"
}
return out
}
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func NormalizePhone(phone string) string {
var b strings.Builder
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
out := b.String()
out = strings.TrimPrefix(out, "00")
return out
}
func NormalizeName(name string) string {
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(name))), " ")
}
func PathSlug(path string) string {
return filepath.Base(filepath.Dir(path))
}

View File

@ -0,0 +1,27 @@
package model
import "testing"
func TestSlugStable(t *testing.T) {
if got := Slug("Sally O'Malley"); got != "sally-o-malley" {
t.Fatalf("Slug = %q", got)
}
if got := NormalizePhone("+1 (415) 734-7847"); got != "14157347847" {
t.Fatalf("NormalizePhone = %q", got)
}
if got := NormalizePhone("0043 664 104 2436"); got != "436641042436" {
t.Fatalf("NormalizePhone 00 = %q", got)
}
if got := NormalizeEmail(" ADA@Example.COM "); got != "ada@example.com" {
t.Fatalf("NormalizeEmail = %q", got)
}
if got := NormalizeName(" Ada Lovelace "); got != "ada lovelace" {
t.Fatalf("NormalizeName = %q", got)
}
if got := PathSlug("/tmp/ada/person.md"); got != "ada" {
t.Fatalf("PathSlug = %q", got)
}
if got := Slug("***"); got != "person" {
t.Fatalf("empty Slug = %q", got)
}
}

20
internal/model/source.go Normal file
View File

@ -0,0 +1,20 @@
package model
type SourceContact struct {
Source string `json:"source"`
ExternalID string `json:"external_id,omitempty"`
Name string `json:"name"`
Tags []string `json:"tags,omitempty"`
Emails []ContactValue `json:"emails,omitempty"`
Phones []ContactValue `json:"phones,omitempty"`
Accounts map[string][]string `json:"accounts,omitempty"`
ETag string `json:"etag,omitempty"`
}
type ImportChange struct {
Action string `json:"action"`
PersonID string `json:"person_id,omitempty"`
Name string `json:"name"`
Source SourceContact `json:"source"`
Path string `json:"path,omitempty"`
}

63
internal/model/types.go Normal file
View File

@ -0,0 +1,63 @@
package model
import "time"
type ContactValue struct {
Value string `json:"value" yaml:"value"`
Label string `json:"label,omitempty" yaml:"label,omitempty"`
Source string `json:"source,omitempty" yaml:"source,omitempty"`
Primary bool `json:"primary,omitempty" yaml:"primary,omitempty"`
}
type ExternalRef struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
Resource string `json:"resource,omitempty" yaml:"resource,omitempty"`
ETag string `json:"etag,omitempty" yaml:"etag,omitempty"`
LastSeenAt time.Time `json:"last_seen_at,omitzero" yaml:"last_seen_at,omitempty"`
}
type Person struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
SortName string `json:"sort_name,omitempty" yaml:"sort_name,omitempty"`
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
Emails []ContactValue `json:"emails,omitempty" yaml:"emails,omitempty"`
Phones []ContactValue `json:"phones,omitempty" yaml:"phones,omitempty"`
Accounts map[string][]string `json:"accounts,omitempty" yaml:"accounts,omitempty"`
Apple ExternalRef `json:"apple,omitzero" yaml:"apple,omitempty"`
Google ExternalRef `json:"google,omitzero" yaml:"google,omitempty"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
Path string `json:"path,omitempty" yaml:"-"`
Body string `json:"body,omitempty" yaml:"-"`
Extra map[string]map[string]any `json:"extra,omitempty" yaml:"-"`
}
type Note struct {
ID string `json:"id" yaml:"id"`
PersonID string `json:"person_id" yaml:"person_id"`
OccurredAt time.Time `json:"occurred_at" yaml:"occurred_at"`
CapturedAt time.Time `json:"captured_at" yaml:"captured_at"`
Kind string `json:"kind" yaml:"kind"`
Source string `json:"source" yaml:"source"`
Account string `json:"account,omitempty" yaml:"account,omitempty"`
ExternalID string `json:"external_id,omitempty" yaml:"external_id,omitempty"`
Direction string `json:"direction,omitempty" yaml:"direction,omitempty"`
Confidence string `json:"confidence,omitempty" yaml:"confidence,omitempty"`
Topics []string `json:"topics,omitempty" yaml:"topics,omitempty"`
FollowUpAt time.Time `json:"follow_up_at,omitzero" yaml:"follow_up_at,omitempty"`
Privacy string `json:"privacy,omitempty" yaml:"privacy,omitempty"`
Path string `json:"path,omitempty" yaml:"-"`
Body string `json:"body,omitempty" yaml:"-"`
}
type SearchHit struct {
Kind string `json:"kind"`
ID string `json:"id"`
PersonID string `json:"person_id,omitempty"`
Name string `json:"name,omitempty"`
Path string `json:"path"`
Score int `json:"score"`
Snippet string `json:"snippet,omitempty"`
Timestamp time.Time `json:"timestamp,omitzero"`
}

133
internal/repo/config.go Normal file
View File

@ -0,0 +1,133 @@
package repo
import (
"errors"
"os"
"path/filepath"
"strings"
crawlconfig "github.com/openclaw/crawlkit/config"
)
const (
DefaultConfigEnv = "CLAWDEX_CONFIG"
RepoEnv = "CLAWDEX_REPO"
DefaultRemote = "https://github.com/steipete/backup-clawdex.git"
)
type Config struct {
Version int `toml:"version" json:"version"`
RepoPath string `toml:"repo_path" json:"repo_path"`
Git GitConfig `toml:"git" json:"git"`
Repair Repair `toml:"repair" json:"repair"`
Google Google `toml:"google" json:"google"`
Apple Apple `toml:"apple" json:"apple"`
}
type GitConfig struct {
Remote string `toml:"remote" json:"remote"`
Branch string `toml:"branch" json:"branch"`
AutoPull bool `toml:"auto_pull" json:"auto_pull"`
AutoPush bool `toml:"auto_push" json:"auto_push"`
}
type Repair struct {
AutoRepair bool `toml:"auto_repair" json:"auto_repair"`
BackupBeforeRepair bool `toml:"backup_before_repair" json:"backup_before_repair"`
}
type Google struct {
DefaultAccount string `toml:"default_account" json:"default_account"`
Adapter string `toml:"adapter" json:"adapter"`
}
type Apple struct {
Enabled bool `toml:"enabled" json:"enabled"`
}
var appConfig = crawlconfig.App{Name: "clawdex", ConfigEnv: DefaultConfigEnv, BaseDir: "~/.clawdex"}
func DefaultConfig() Config {
paths, err := appConfig.DefaultPaths()
if err != nil {
home, _ := os.UserHomeDir()
paths.ShareDir = filepath.Join(home, ".clawdex", "contacts")
}
return Config{
Version: 1,
RepoPath: paths.ShareDir,
Git: GitConfig{
Remote: DefaultRemote,
Branch: "main",
},
Repair: Repair{
AutoRepair: true,
BackupBeforeRepair: true,
},
Google: Google{Adapter: "gog"},
Apple: Apple{Enabled: true},
}
}
func ResolveConfigPath(flagPath string) string {
path, err := appConfig.ResolveConfigPath(flagPath)
if err != nil {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".clawdex", "config.toml")
}
return path
}
func LoadConfig(path string) (Config, error) {
cfg := DefaultConfig()
if _, err := os.Stat(crawlconfig.ExpandHome(path)); err != nil {
if errors.Is(err, os.ErrNotExist) {
cfg.Normalize()
return cfg, nil
}
return Config{}, err
}
if err := crawlconfig.LoadTOML(path, &cfg); err != nil {
return Config{}, err
}
cfg.Normalize()
return cfg, nil
}
func WriteConfig(path string, cfg Config) error {
cfg.Normalize()
return crawlconfig.WriteTOML(path, cfg, 0o600)
}
func (c *Config) Normalize() {
def := DefaultConfig()
if c.Version == 0 {
c.Version = 1
}
if strings.TrimSpace(c.RepoPath) == "" {
c.RepoPath = def.RepoPath
}
c.RepoPath = crawlconfig.ExpandHome(c.RepoPath)
if strings.TrimSpace(c.Git.Remote) == "" {
c.Git.Remote = def.Git.Remote
}
if strings.TrimSpace(c.Git.Branch) == "" {
c.Git.Branch = "main"
}
if strings.TrimSpace(c.Google.Adapter) == "" {
c.Google.Adapter = "gog"
}
}
func ResolveRepoPath(flagRepo string, cfg Config) (string, error) {
switch {
case strings.TrimSpace(flagRepo) != "":
return crawlconfig.ExpandHome(flagRepo), nil
case strings.TrimSpace(os.Getenv(RepoEnv)) != "":
return crawlconfig.ExpandHome(os.Getenv(RepoEnv)), nil
case strings.TrimSpace(cfg.RepoPath) != "":
return crawlconfig.ExpandHome(cfg.RepoPath), nil
default:
return "", errors.New("contacts repo not configured; run clawdex init DIR or pass --repo DIR")
}
}

27
internal/repo/git.go Normal file
View File

@ -0,0 +1,27 @@
package repo
import (
"context"
"github.com/openclaw/crawlkit/mirror"
)
func (r Repo) MirrorOptions() mirror.Options {
return mirror.Options{RepoPath: r.Path, Remote: r.Config.Git.Remote, Branch: r.Config.Git.Branch}
}
func (r Repo) Pull(ctx context.Context) error {
return mirror.PullCurrent(ctx, r.MirrorOptions())
}
func (r Repo) Push(ctx context.Context) error {
return mirror.Push(ctx, r.MirrorOptions())
}
func (r Repo) Commit(ctx context.Context, message string) (bool, error) {
return mirror.Commit(ctx, r.MirrorOptions(), message)
}
func (r Repo) Dirty(ctx context.Context) (bool, error) {
return mirror.Dirty(ctx, r.MirrorOptions())
}

83
internal/repo/repo.go Normal file
View File

@ -0,0 +1,83 @@
package repo
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/openclaw/crawlkit/mirror"
)
type Repo struct {
Path string
Config Config
}
func Open(path string, cfg Config) Repo {
return Repo{Path: path, Config: cfg}
}
func (r Repo) Init(ctx context.Context) error {
if strings.TrimSpace(r.Path) == "" {
return errors.New("repo path is required")
}
for _, dir := range []string{
filepath.Join(r.Path, "people"),
filepath.Join(r.Path, "index"),
filepath.Join(r.Path, ".clawdex", "repairs"),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
if err := mirror.EnsureRepo(ctx, mirror.Options{RepoPath: r.Path, Branch: r.Config.Git.Branch}); err != nil {
return err
}
if err := writeDataConfig(filepath.Join(r.Path, "clawdex.toml"), r.Config); err != nil {
return err
}
if strings.TrimSpace(r.Config.Git.Remote) != "" {
if err := mirror.EnsureRemote(ctx, mirror.Options{RepoPath: r.Path, Remote: r.Config.Git.Remote, Branch: r.Config.Git.Branch}); err != nil {
return err
}
}
return nil
}
func (r Repo) Require() error {
if strings.TrimSpace(r.Path) == "" {
return errors.New("repo path is required")
}
if _, err := os.Stat(filepath.Join(r.Path, "people")); err != nil {
return fmt.Errorf("contacts repo not initialized at %s; run clawdex init %s", r.Path, r.Path)
}
return nil
}
func (r Repo) PeopleDir() string {
return filepath.Join(r.Path, "people")
}
func (r Repo) IndexDir() string {
return filepath.Join(r.Path, "index")
}
func (r Repo) RepairDir() string {
return filepath.Join(r.Path, ".clawdex", "repairs")
}
func writeDataConfig(path string, cfg Config) error {
data := []byte("version = 1\n")
if strings.TrimSpace(cfg.Git.Remote) != "" {
data = append(data, []byte("\n[git]\nremote = \""+escapeTOML(cfg.Git.Remote)+"\"\nbranch = \""+escapeTOML(cfg.Git.Branch)+"\"\n")...)
}
return os.WriteFile(path, data, 0o600)
}
func escapeTOML(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
return strings.ReplaceAll(s, "\"", "\\\"")
}

172
internal/repo/repo_test.go Normal file
View File

@ -0,0 +1,172 @@
package repo
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestConfigLoadWriteAndResolve(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
cfg := DefaultConfig()
cfg.RepoPath = filepath.Join(dir, "contacts")
cfg.Git.Remote = ""
if err := WriteConfig(path, cfg); err != nil {
t.Fatal(err)
}
loaded, err := LoadConfig(path)
if err != nil {
t.Fatal(err)
}
if loaded.RepoPath != cfg.RepoPath || loaded.Git.Branch != "main" {
t.Fatalf("loaded = %#v", loaded)
}
if got, err := ResolveRepoPath("", loaded); err != nil || got != cfg.RepoPath {
t.Fatalf("repo path = %q err=%v", got, err)
}
t.Setenv(RepoEnv, filepath.Join(dir, "envrepo"))
if got, _ := ResolveRepoPath("", loaded); !strings.HasSuffix(got, "envrepo") {
t.Fatalf("env repo path = %q", got)
}
}
func TestNormalizeFillsDefaults(t *testing.T) {
cfg := Config{}
cfg.Normalize()
if cfg.Version != 1 || cfg.RepoPath == "" || cfg.Git.Remote != DefaultRemote || cfg.Git.Branch != "main" || cfg.Google.Adapter != "gog" {
t.Fatalf("cfg = %#v", cfg)
}
got, err := ResolveRepoPath("/tmp/direct", cfg)
if err != nil || got != "/tmp/direct" {
t.Fatalf("direct repo = %q err=%v", got, err)
}
t.Setenv(RepoEnv, "")
if _, err := ResolveRepoPath("", Config{}); err == nil {
t.Fatal("expected empty repo config error")
}
}
func TestLoadConfigMissingAndBad(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.toml")
cfg, err := LoadConfig(missing)
if err != nil {
t.Fatal(err)
}
if cfg.Git.Remote != DefaultRemote {
t.Fatalf("cfg = %#v", cfg)
}
bad := filepath.Join(dir, "bad.toml")
if err := os.WriteFile(bad, []byte("["), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadConfig(bad); err == nil {
t.Fatal("expected bad config error")
}
if got := ResolveConfigPath(missing); got != missing {
t.Fatalf("config path = %q", got)
}
}
func TestRepoInitRequireAndGit(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
cfg.RepoPath = dir
cfg.Git.Remote = ""
r := Open(dir, cfg)
if err := r.Init(t.Context()); err != nil {
t.Fatal(err)
}
for _, path := range []string{r.PeopleDir(), r.IndexDir(), r.RepairDir(), filepath.Join(dir, ".git"), filepath.Join(dir, "clawdex.toml")} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("missing %s: %v", path, err)
}
}
if err := r.Require(); err != nil {
t.Fatal(err)
}
if opts := r.MirrorOptions(); opts.RepoPath != dir || opts.Branch != "main" {
t.Fatalf("opts = %#v", opts)
}
dirty, err := r.Dirty(t.Context())
if err != nil {
t.Fatal(err)
}
if !dirty {
t.Fatal("expected dirty repo after init")
}
committed, err := r.Commit(t.Context(), "test: init")
if err != nil {
t.Fatal(err)
}
if !committed {
t.Fatal("expected commit")
}
}
func TestRepoGuardErrors(t *testing.T) {
cfg := DefaultConfig()
if err := Open("", cfg).Init(t.Context()); err == nil {
t.Fatal("expected empty init path error")
}
if err := Open("", cfg).Require(); err == nil {
t.Fatal("expected empty require path error")
}
missing := filepath.Join(t.TempDir(), "missing")
if err := Open(missing, cfg).Require(); err == nil || !strings.Contains(err.Error(), "not initialized") {
t.Fatalf("require missing err = %v", err)
}
}
func TestRequireFailsBeforeInit(t *testing.T) {
err := Open(t.TempDir(), DefaultConfig()).Require()
if err == nil || !strings.Contains(err.Error(), "not initialized") {
t.Fatalf("err = %v", err)
}
if got := escapeTOML(`a"b\c`); got != `a\"b\\c` {
t.Fatalf("escaped = %q", got)
}
}
func TestRepoInitGuardsAndRemoteLocal(t *testing.T) {
if err := Open("", DefaultConfig()).Init(t.Context()); err == nil {
t.Fatal("expected empty path error")
}
dir := t.TempDir()
remote := filepath.Join(dir, "remote.git")
if err := os.Mkdir(remote, 0o755); err != nil {
t.Fatal(err)
}
runGit(t, remote, "init", "--bare")
cfg := DefaultConfig()
cfg.RepoPath = filepath.Join(dir, "work")
cfg.Git.Remote = remote
r := Open(cfg.RepoPath, cfg)
if err := r.Init(t.Context()); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfg.RepoPath, "people", "x"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if committed, err := r.Commit(t.Context(), "test: remote"); err != nil || !committed {
t.Fatalf("commit=%v err=%v", committed, err)
}
if err := r.Push(t.Context()); err != nil {
t.Fatal(err)
}
if err := r.Pull(t.Context()); err != nil {
t.Fatal(err)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(t.Context(), "git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

108
internal/vcard/vcard.go Normal file
View File

@ -0,0 +1,108 @@
package vcard
import (
"fmt"
"io"
"strings"
"unicode/utf8"
"github.com/openclaw/clawdex/internal/model"
)
func Write(w io.Writer, people []model.Person) error {
for _, p := range people {
if err := writeOne(w, p); err != nil {
return err
}
}
return nil
}
func writeOne(w io.Writer, p model.Person) error {
lines := []string{
"BEGIN:VCARD",
"VERSION:4.0",
"UID:" + escape(p.ID),
"FN:" + escape(p.Name),
"N:" + structuredName(p),
}
for _, email := range p.Emails {
if strings.TrimSpace(email.Value) == "" {
continue
}
lines = append(lines, "EMAIL"+typeParam(email.Label)+":"+escape(email.Value))
}
for _, phone := range p.Phones {
if strings.TrimSpace(phone.Value) == "" {
continue
}
lines = append(lines, "TEL"+typeParam(phone.Label)+":"+escape(phone.Value))
}
if len(p.Tags) > 0 {
lines = append(lines, "CATEGORIES:"+escape(strings.Join(p.Tags, ",")))
}
lines = append(lines, "NOTE:"+escape("clawdex:"+p.ID))
lines = append(lines, "END:VCARD")
for _, line := range lines {
if err := folded(w, line); err != nil {
return err
}
}
return nil
}
func structuredName(p model.Person) string {
name := strings.Fields(p.Name)
if len(name) == 0 {
return ";;;;"
}
if len(name) == 1 {
return escape(name[0]) + ";;;;"
}
family := name[len(name)-1]
given := strings.Join(name[:len(name)-1], " ")
return escape(family) + ";" + escape(given) + ";;;"
}
func typeParam(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return ""
}
label = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
return r
}
return -1
}, label)
if label == "" {
return ""
}
return ";TYPE=" + label
}
func escape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
return s
}
func folded(w io.Writer, line string) error {
const limit = 75
for len(line) > limit {
cut := limit
for !utf8.ValidString(line[:cut]) {
cut--
}
if _, err := fmt.Fprint(w, line[:cut]+"\r\n "); err != nil {
return err
}
line = line[cut:]
}
_, err := fmt.Fprint(w, line+"\r\n")
return err
}

View File

@ -0,0 +1,80 @@
package vcard
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/openclaw/clawdex/internal/model"
)
func TestWriteVCard(t *testing.T) {
var buf bytes.Buffer
person := model.Person{
ID: "person_1",
Name: "Ada Lovelace",
Tags: []string{"math"},
Emails: []model.ContactValue{{Value: "ada@example.com", Label: "home"}},
Phones: []model.ContactValue{{Value: "+1 555 0100", Label: "mobile"}},
}
if err := Write(&buf, []model.Person{person}); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{"BEGIN:VCARD", "UID:person_1", "FN:Ada Lovelace", "EMAIL;TYPE=home:ada@example.com", "TEL;TYPE=mobile:+1 555 0100", "NOTE:clawdex:person_1"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %s", want, out)
}
}
}
func TestVCardHelpers(t *testing.T) {
if got := structuredName(model.Person{Name: "Ada Lovelace"}); got != "Lovelace;Ada;;;" {
t.Fatalf("structured = %q", got)
}
if got := structuredName(model.Person{}); got != ";;;;" {
t.Fatalf("empty structured = %q", got)
}
if got := typeParam("Mobile Phone!"); got != ";TYPE=mobilephone" {
t.Fatalf("type = %q", got)
}
if got := typeParam("!!!"); got != "" {
t.Fatalf("invalid type = %q", got)
}
if got := escape("a,b;c\\d\ne"); got != `a\,b\;c\\d\ne` {
t.Fatalf("escape = %q", got)
}
var buf bytes.Buffer
if err := folded(&buf, strings.Repeat("a", 90)); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "\r\n ") {
t.Fatalf("not folded: %q", buf.String())
}
}
func TestWriteSkipsEmptyValuesAndEmptyList(t *testing.T) {
var buf bytes.Buffer
if err := Write(&buf, nil); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Fatalf("buf = %q", buf.String())
}
err := Write(errWriter{}, []model.Person{{ID: "p", Name: "A"}})
if err == nil {
t.Fatal("expected writer error")
}
buf.Reset()
if err := Write(&buf, []model.Person{{ID: "p", Name: "Solo", Emails: []model.ContactValue{{}}, Phones: []model.ContactValue{{}}, Tags: []string{"one", "two"}}}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "EMAIL") || strings.Contains(buf.String(), "TEL") || !strings.Contains(buf.String(), "CATEGORIES:one\\,two") {
t.Fatalf("vcard = %s", buf.String())
}
}
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }