From fc837601e11b8e8a2e4dfde3346e74d488456c45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:24:58 +0100 Subject: [PATCH] feat: bootstrap clawdex cli --- .github/workflows/ci.yml | 187 +++++++ .github/workflows/release.yml | 106 ++++ .gitignore | 4 + .golangci.yml | 46 ++ .goreleaser.yaml | 36 ++ CHANGELOG.md | 8 + README.md | 128 +++++ cmd/clawdex/main.go | 21 + cmd/clawdex/main_test.go | 21 + docs/RELEASING.md | 94 ++++ go.mod | 12 + go.sum | 18 + internal/apple/apple.go | 94 ++++ internal/apple/apple_test.go | 55 +++ internal/apple/contacts_export.swift | 84 ++++ internal/apple/darwin.go | 57 +++ internal/apple/darwin_test.go | 46 ++ internal/apple/other.go | 12 + internal/apple/other_test.go | 12 + internal/birdclaw/birdclaw.go | 133 +++++ internal/birdclaw/birdclaw_test.go | 84 ++++ internal/cli/cli.go | 704 +++++++++++++++++++++++++++ internal/cli/cli_test.go | 557 +++++++++++++++++++++ internal/discrawl/discrawl.go | 148 ++++++ internal/discrawl/discrawl_test.go | 70 +++ internal/google/gog.go | 154 ++++++ internal/google/gog_test.go | 87 ++++ internal/index/import.go | 289 +++++++++++ internal/index/store.go | 345 +++++++++++++ internal/index/store_test.go | 364 ++++++++++++++ internal/markdown/markdown.go | 451 +++++++++++++++++ internal/markdown/markdown_test.go | 204 ++++++++ internal/match/match.go | 33 ++ internal/match/match_test.go | 39 ++ internal/model/normalize.go | 52 ++ internal/model/normalize_test.go | 27 + internal/model/source.go | 20 + internal/model/types.go | 63 +++ internal/repo/config.go | 133 +++++ internal/repo/git.go | 27 + internal/repo/repo.go | 83 ++++ internal/repo/repo_test.go | 172 +++++++ internal/vcard/vcard.go | 108 ++++ internal/vcard/vcard_test.go | 80 +++ 44 files changed, 5468 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 cmd/clawdex/main.go create mode 100644 cmd/clawdex/main_test.go create mode 100644 docs/RELEASING.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/apple/apple.go create mode 100644 internal/apple/apple_test.go create mode 100644 internal/apple/contacts_export.swift create mode 100644 internal/apple/darwin.go create mode 100644 internal/apple/darwin_test.go create mode 100644 internal/apple/other.go create mode 100644 internal/apple/other_test.go create mode 100644 internal/birdclaw/birdclaw.go create mode 100644 internal/birdclaw/birdclaw_test.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/discrawl/discrawl.go create mode 100644 internal/discrawl/discrawl_test.go create mode 100644 internal/google/gog.go create mode 100644 internal/google/gog_test.go create mode 100644 internal/index/import.go create mode 100644 internal/index/store.go create mode 100644 internal/index/store_test.go create mode 100644 internal/markdown/markdown.go create mode 100644 internal/markdown/markdown_test.go create mode 100644 internal/match/match.go create mode 100644 internal/match/match_test.go create mode 100644 internal/model/normalize.go create mode 100644 internal/model/normalize_test.go create mode 100644 internal/model/source.go create mode 100644 internal/model/types.go create mode 100644 internal/repo/config.go create mode 100644 internal/repo/git.go create mode 100644 internal/repo/repo.go create mode 100644 internal/repo/repo_test.go create mode 100644 internal/vcard/vcard.go create mode 100644 internal/vcard/vcard_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8c94951 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b8c1491 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77dbe16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +coverage.out +/dist/ +/clawdex diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..260645d --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..ec82255 --- /dev/null +++ b/.goreleaser.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7a6f483 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7855681 --- /dev/null +++ b/README.md @@ -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). diff --git a/cmd/clawdex/main.go b/cmd/clawdex/main.go new file mode 100644 index 0000000..ad90de6 --- /dev/null +++ b/cmd/clawdex/main.go @@ -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 +} diff --git a/cmd/clawdex/main_test.go b/cmd/clawdex/main_test.go new file mode 100644 index 0000000..b34e263 --- /dev/null +++ b/cmd/clawdex/main_test.go @@ -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) + } +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..2658b87 --- /dev/null +++ b/docs/RELEASING.md @@ -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 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d031213 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c56a809 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/apple/apple.go b/internal/apple/apple.go new file mode 100644 index 0000000..ae65af8 --- /dev/null +++ b/internal/apple/apple.go @@ -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 +} diff --git a/internal/apple/apple_test.go b/internal/apple/apple_test.go new file mode 100644 index 0000000..e6a27e8 --- /dev/null +++ b/internal/apple/apple_test.go @@ -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") + } +} diff --git a/internal/apple/contacts_export.swift b/internal/apple/contacts_export.swift new file mode 100644 index 0000000..ec9d167 --- /dev/null +++ b/internal/apple/contacts_export.swift @@ -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)") +} diff --git a/internal/apple/darwin.go b/internal/apple/darwin.go new file mode 100644 index 0000000..6d00366 --- /dev/null +++ b/internal/apple/darwin.go @@ -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) +} diff --git a/internal/apple/darwin_test.go b/internal/apple/darwin_test.go new file mode 100644 index 0000000..385c94d --- /dev/null +++ b/internal/apple/darwin_test.go @@ -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) + } +} diff --git a/internal/apple/other.go b/internal/apple/other.go new file mode 100644 index 0000000..f1c820b --- /dev/null +++ b/internal/apple/other.go @@ -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") +} diff --git a/internal/apple/other_test.go b/internal/apple/other_test.go new file mode 100644 index 0000000..391e1ce --- /dev/null +++ b/internal/apple/other_test.go @@ -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") + } +} diff --git a/internal/birdclaw/birdclaw.go b/internal/birdclaw/birdclaw.go new file mode 100644 index 0000000..3f47516 --- /dev/null +++ b/internal/birdclaw/birdclaw.go @@ -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)); +` diff --git a/internal/birdclaw/birdclaw_test.go b/internal/birdclaw/birdclaw_test.go new file mode 100644 index 0000000..19ece81 --- /dev/null +++ b/internal/birdclaw/birdclaw_test.go @@ -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) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..8e1907a --- /dev/null +++ b/internal/cli/cli.go @@ -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 "" +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..fb4449d --- /dev/null +++ b/internal/cli/cli_test.go @@ -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) + } +} diff --git a/internal/discrawl/discrawl.go b/internal/discrawl/discrawl.go new file mode 100644 index 0000000..bb4a449 --- /dev/null +++ b/internal/discrawl/discrawl.go @@ -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); +` diff --git a/internal/discrawl/discrawl_test.go b/internal/discrawl/discrawl_test.go new file mode 100644 index 0000000..c393179 --- /dev/null +++ b/internal/discrawl/discrawl_test.go @@ -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) + } +} diff --git a/internal/google/gog.go b/internal/google/gog.go new file mode 100644 index 0000000..4fb1a50 --- /dev/null +++ b/internal/google/gog.go @@ -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 "" +} diff --git a/internal/google/gog_test.go b/internal/google/gog_test.go new file mode 100644 index 0000000..d65700a --- /dev/null +++ b/internal/google/gog_test.go @@ -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) + } +} diff --git a/internal/index/import.go b/internal/index/import.go new file mode 100644 index 0000000..3d827c1 --- /dev/null +++ b/internal/index/import.go @@ -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 +} diff --git a/internal/index/store.go b/internal/index/store.go new file mode 100644 index 0000000..69e1583 --- /dev/null +++ b/internal/index/store.go @@ -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]) +} diff --git a/internal/index/store_test.go b/internal/index/store_test.go new file mode 100644 index 0000000..a580069 --- /dev/null +++ b/internal/index/store_test.go @@ -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) +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go new file mode 100644 index 0000000..bf76a1c --- /dev/null +++ b/internal/markdown/markdown.go @@ -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) +} diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go new file mode 100644 index 0000000..a29fe7b --- /dev/null +++ b/internal/markdown/markdown_test.go @@ -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()) +} diff --git a/internal/match/match.go b/internal/match/match.go new file mode 100644 index 0000000..f91cac8 --- /dev/null +++ b/internal/match/match.go @@ -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 +} diff --git a/internal/match/match_test.go b/internal/match/match_test.go new file mode 100644 index 0000000..80ac101 --- /dev/null +++ b/internal/match/match_test.go @@ -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) + } + } +} diff --git a/internal/model/normalize.go b/internal/model/normalize.go new file mode 100644 index 0000000..ca6d3cb --- /dev/null +++ b/internal/model/normalize.go @@ -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)) +} diff --git a/internal/model/normalize_test.go b/internal/model/normalize_test.go new file mode 100644 index 0000000..8386c51 --- /dev/null +++ b/internal/model/normalize_test.go @@ -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) + } +} diff --git a/internal/model/source.go b/internal/model/source.go new file mode 100644 index 0000000..58a462d --- /dev/null +++ b/internal/model/source.go @@ -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"` +} diff --git a/internal/model/types.go b/internal/model/types.go new file mode 100644 index 0000000..6e1f4af --- /dev/null +++ b/internal/model/types.go @@ -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"` +} diff --git a/internal/repo/config.go b/internal/repo/config.go new file mode 100644 index 0000000..38bd3bd --- /dev/null +++ b/internal/repo/config.go @@ -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") + } +} diff --git a/internal/repo/git.go b/internal/repo/git.go new file mode 100644 index 0000000..9a26f44 --- /dev/null +++ b/internal/repo/git.go @@ -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()) +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..ca1c0bf --- /dev/null +++ b/internal/repo/repo.go @@ -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, "\"", "\\\"") +} diff --git a/internal/repo/repo_test.go b/internal/repo/repo_test.go new file mode 100644 index 0000000..c1512b6 --- /dev/null +++ b/internal/repo/repo_test.go @@ -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) + } +} diff --git a/internal/vcard/vcard.go b/internal/vcard/vcard.go new file mode 100644 index 0000000..fe3c6e7 --- /dev/null +++ b/internal/vcard/vcard.go @@ -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 +} diff --git a/internal/vcard/vcard_test.go b/internal/vcard/vcard_test.go new file mode 100644 index 0000000..de5b422 --- /dev/null +++ b/internal/vcard/vcard_test.go @@ -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") }