feat: bootstrap clawdex cli
This commit is contained in:
parent
82e9ed8237
commit
fc837601e1
187
.github/workflows/ci.yml
vendored
Normal file
187
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,187 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v9.2.0
|
||||
with:
|
||||
version: v2.12.1
|
||||
|
||||
- name: Install analyzers
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
|
||||
go install mvdan.cc/gofumpt@v0.9.2
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Staticcheck
|
||||
run: '"$(go env GOPATH)/bin/staticcheck" ./...'
|
||||
|
||||
- name: Gofumpt
|
||||
run: |
|
||||
changed="$("$(go env GOPATH)/bin/gofumpt" -l .)"
|
||||
if [ -n "$changed" ]; then
|
||||
printf 'gofumpt wants changes in:\n%s\n' "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Gosec
|
||||
run: |
|
||||
"$(go env GOPATH)/bin/gosec" -exclude=G101,G115,G202,G301,G304 ./...
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test with coverage
|
||||
run: go test -count=1 ./... -coverprofile=coverage.out
|
||||
|
||||
- name: Test with race detector
|
||||
run: go test -count=1 -race ./...
|
||||
|
||||
- name: Enforce coverage floor
|
||||
run: |
|
||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%$/, "", $3); print $3 }')"
|
||||
awk -v total="$total" 'BEGIN {
|
||||
if (total == "") {
|
||||
print "missing coverage total"
|
||||
exit 1
|
||||
}
|
||||
if (total + 0 < 90.0) {
|
||||
printf("coverage %.1f%% is below 90%%\n", total + 0)
|
||||
exit 1
|
||||
}
|
||||
printf("coverage %.1f%%\n", total + 0)
|
||||
}'
|
||||
|
||||
- name: Build
|
||||
run: go build -o bin/clawdex ./cmd/clawdex
|
||||
|
||||
- name: Smoke test CLI control surface
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$(./bin/clawdex --version)"
|
||||
output="$(./bin/clawdex --help)"
|
||||
printf '%s\n' "$output"
|
||||
printf '%s' "$output" | grep -q "person"
|
||||
printf '%s' "$output" | grep -q "import"
|
||||
tmp="$(mktemp -d)"
|
||||
cfg="$tmp/config.toml"
|
||||
repo="$tmp/contacts"
|
||||
./bin/clawdex --config "$cfg" init "$repo" --remote ""
|
||||
./bin/clawdex --config "$cfg" --plain person add "CI Person" --email ci@example.com
|
||||
./bin/clawdex --config "$cfg" --json person show ci@example.com | grep -q '"name": "CI Person"'
|
||||
./bin/clawdex --config "$cfg" note add ci@example.com --kind note --source manual --text "release smoke"
|
||||
./bin/clawdex --config "$cfg" --plain search release | grep -q "CI Person"
|
||||
./bin/clawdex --config "$cfg" doctor --repair --dry-run
|
||||
|
||||
deps:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify module cache
|
||||
run: go mod verify
|
||||
|
||||
- name: Check go.mod tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code -- go.mod go.sum
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||
|
||||
- name: Run govulncheck
|
||||
run: '"$(go env GOPATH)/bin/govulncheck" ./...'
|
||||
|
||||
release-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Snapshot release build
|
||||
uses: goreleaser/goreleaser-action@v7.2.1
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --snapshot --clean --skip=publish
|
||||
|
||||
secrets:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install gitleaks
|
||||
run: go install github.com/zricethezav/gitleaks/v8@v8.30.1
|
||||
|
||||
- name: Scan git history
|
||||
run: |
|
||||
"$(go env GOPATH)/bin/gitleaks" git --no-banner --redact
|
||||
|
||||
- name: Scan working tree
|
||||
run: |
|
||||
"$(go env GOPATH)/bin/gitleaks" dir . --no-banner --redact
|
||||
106
.github/workflows/release.yml
vendored
Normal file
106
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to (re)release (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Stash GoReleaser config
|
||||
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7.2.1
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean --config /tmp/.goreleaser.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-homebrew-tap:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Dispatch tap formula update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
request_id="clawdex-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
expected_title="Update clawdex for ${RELEASE_TAG} (${request_id})"
|
||||
|
||||
gh workflow run update-formula.yml \
|
||||
--repo steipete/homebrew-tap \
|
||||
--ref main \
|
||||
-f formula=clawdex \
|
||||
-f tag="$RELEASE_TAG" \
|
||||
-f repository=openclaw/clawdex \
|
||||
-f description="Local-first personal contact index CLI backed by Markdown and Git" \
|
||||
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
|
||||
-f request_id="$request_id"
|
||||
|
||||
run_id=""
|
||||
for _ in {1..30}; do
|
||||
run_id=$(gh run list \
|
||||
--repo steipete/homebrew-tap \
|
||||
--workflow update-formula.yml \
|
||||
--branch main \
|
||||
--event workflow_dispatch \
|
||||
--limit 20 \
|
||||
--json databaseId,displayTitle \
|
||||
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::Could not find tap workflow run with title: $expected_title"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh run watch "$run_id" \
|
||||
--repo steipete/homebrew-tap \
|
||||
--exit-status \
|
||||
--interval 10
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
coverage.out
|
||||
/dist/
|
||||
/clawdex
|
||||
46
.golangci.yml
Normal file
46
.golangci.yml
Normal file
@ -0,0 +1,46 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- canonicalheader
|
||||
- copyloopvar
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exptostd
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- gomoddirectives
|
||||
- govet
|
||||
- intrange
|
||||
- ineffassign
|
||||
- makezero
|
||||
- misspell
|
||||
- modernize
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- perfsprint
|
||||
- predeclared
|
||||
- rowserrcheck
|
||||
- sloglint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- testifylint
|
||||
- unconvert
|
||||
- unused
|
||||
- usetesting
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
36
.goreleaser.yaml
Normal file
36
.goreleaser.yaml
Normal file
@ -0,0 +1,36 @@
|
||||
version: 2
|
||||
|
||||
project_name: clawdex
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
builds:
|
||||
- id: clawdex
|
||||
main: ./cmd/clawdex
|
||||
binary: clawdex
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X github.com/openclaw/clawdex/internal/cli.Version={{ .Version }}
|
||||
targets:
|
||||
- darwin_amd64
|
||||
- darwin_arm64
|
||||
- linux_amd64
|
||||
- linux_arm64
|
||||
- windows_amd64
|
||||
- windows_arm64
|
||||
|
||||
archives:
|
||||
- ids:
|
||||
- clawdex
|
||||
formats:
|
||||
- tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Initial `clawdex` CLI with markdown-backed people, timestamped notes, search, timeline, Git helpers, vCard export, and repair for damaged frontmatter.
|
||||
- Added Apple Contacts import on macOS, Google Contacts import through `gog`, Discord DM backfill through Discrawl, and X/Twitter DM backfill through Birdclaw.
|
||||
- Added CI with lint, tests, 90% coverage enforcement, race tests, dependency checks, secret scanning, and GoReleaser snapshot validation.
|
||||
- Added GoReleaser config and release workflow that publishes cross-platform binaries and dispatches the Homebrew tap formula updater.
|
||||
128
README.md
Normal file
128
README.md
Normal file
@ -0,0 +1,128 @@
|
||||
# clawdex
|
||||
|
||||
`clawdex` is a local-first personal contact index CLI. The app lives in this
|
||||
repo; your contacts live in a separate private Git-backed markdown repo.
|
||||
|
||||
The default backup remote is:
|
||||
|
||||
```bash
|
||||
https://github.com/steipete/backup-clawdex.git
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Install from Homebrew after the first tagged release:
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/clawdex
|
||||
```
|
||||
|
||||
Or build locally:
|
||||
|
||||
```bash
|
||||
go install github.com/openclaw/clawdex/cmd/clawdex@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
clawdex init ~/.clawdex/contacts
|
||||
clawdex config set repo_path ~/.clawdex/contacts
|
||||
clawdex config set git.remote https://github.com/steipete/backup-clawdex.git
|
||||
```
|
||||
|
||||
`init` creates a data repo:
|
||||
|
||||
```text
|
||||
clawdex.toml
|
||||
people/
|
||||
index/
|
||||
.clawdex/repairs/
|
||||
```
|
||||
|
||||
Config is stored at `~/.clawdex/config.toml` by default. `--repo DIR` or
|
||||
`CLAWDEX_REPO=DIR` overrides the configured contacts repo for one run.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdex person add "Sally O'Malley" --email sally@example.com --tag friend
|
||||
clawdex note add sally --kind dm --source whatsapp --text "Follow up about dinner"
|
||||
clawdex person list
|
||||
clawdex person show sally
|
||||
clawdex timeline sally
|
||||
clawdex search dinner
|
||||
clawdex export vcard --all -o contacts.vcf
|
||||
clawdex git status
|
||||
clawdex git commit -m "sync: update contacts"
|
||||
clawdex git push
|
||||
```
|
||||
|
||||
## Imports And Sync Safety
|
||||
|
||||
Apple and Google imports write only to the local markdown data repo.
|
||||
|
||||
```bash
|
||||
clawdex import apple --dry-run
|
||||
clawdex import apple
|
||||
clawdex import google --account steipete@gmail.com --dry-run
|
||||
clawdex import birdclaw --min-messages 4 --dry-run
|
||||
clawdex import discrawl --min-messages 4 --dry-run
|
||||
```
|
||||
|
||||
Apple direct import uses macOS `Contacts.framework`. Linux builds still support
|
||||
markdown, notes, search, Git, Google via `gog`, and vCard export.
|
||||
|
||||
Birdclaw and Discrawl DM imports read local archives only. They import DM
|
||||
conversations with more than `--min-messages` messages, add source-specific
|
||||
tags, and store stable pointers under `accounts.x` or `accounts.discord`.
|
||||
|
||||
`sync apple` and `sync google` are preview-only placeholders for now. Remote
|
||||
address-book writes need a conflict report before they become active. Notes stay
|
||||
local-only and are never written to Apple or Google.
|
||||
|
||||
## Markdown Repair
|
||||
|
||||
People and note files use YAML frontmatter plus a Markdown body. `clawdex`
|
||||
parses strictly first, then does best-effort repair when frontmatter is damaged:
|
||||
|
||||
- salvage known scalar keys such as `id`, `name`, `created_at`, and note fields
|
||||
- infer missing IDs and timestamps
|
||||
- preserve the Markdown body
|
||||
- copy the original file under `.clawdex/repairs/`
|
||||
- append damaged metadata to the body under `Recovered metadata`
|
||||
|
||||
Preview repairs:
|
||||
|
||||
```bash
|
||||
clawdex doctor --repair --dry-run
|
||||
```
|
||||
|
||||
Apply repairs:
|
||||
|
||||
```bash
|
||||
clawdex doctor --repair
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
```text
|
||||
people/
|
||||
sally-o-malley/
|
||||
person.md
|
||||
notes/
|
||||
2026-05-08T09-15-00Z-whatsapp.md
|
||||
attachments/
|
||||
index/
|
||||
emails.json
|
||||
phones.json
|
||||
handles.json
|
||||
```
|
||||
|
||||
The `index/*.json` files are derived and rebuildable. Markdown is canonical.
|
||||
|
||||
## Releases
|
||||
|
||||
Tagged releases are built by GoReleaser for macOS, Linux, and Windows. The
|
||||
release workflow also dispatches `steipete/homebrew-tap` to update
|
||||
`Formula/clawdex.rb` after the GitHub release assets are published.
|
||||
|
||||
Release checklist: [`docs/RELEASING.md`](docs/RELEASING.md).
|
||||
21
cmd/clawdex/main.go
Normal file
21
cmd/clawdex/main.go
Normal file
@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if err := cli.Execute(args, stdout, stderr); err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err)
|
||||
return cli.ExitCode(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
21
cmd/clawdex/main_test.go
Normal file
21
cmd/clawdex/main_test.go
Normal file
@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cfg := filepath.Join(t.TempDir(), "config.toml")
|
||||
code := run([]string{"--config", cfg, "config"}, &out, &errOut)
|
||||
if code != 0 || !strings.Contains(out.String(), "backup-clawdex") {
|
||||
t.Fatalf("code=%d out=%s err=%s", code, out.String(), errOut.String())
|
||||
}
|
||||
code = run([]string{"--bogus"}, &out, &errOut)
|
||||
if code != 2 {
|
||||
t.Fatalf("code=%d", code)
|
||||
}
|
||||
}
|
||||
94
docs/RELEASING.md
Normal file
94
docs/RELEASING.md
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
summary: "Release checklist for clawdex (GitHub release binaries via GoReleaser + Homebrew tap update)"
|
||||
---
|
||||
|
||||
# Releasing `clawdex`
|
||||
|
||||
Always do all steps below. No partial releases.
|
||||
|
||||
Assumptions:
|
||||
- Repo: `openclaw/clawdex`
|
||||
- Binary: `clawdex`
|
||||
- GoReleaser config: `.goreleaser.yaml`
|
||||
- Homebrew tap repo: `~/Projects/homebrew-tap`
|
||||
- Tap workflow: `steipete/homebrew-tap/.github/workflows/update-formula.yml`
|
||||
|
||||
## 0) Prereqs
|
||||
|
||||
- Clean working tree on `main`
|
||||
- Go toolchain from `go.mod`
|
||||
- GitHub CLI authenticated
|
||||
- CI green on `main`
|
||||
- `HOMEBREW_TAP_TOKEN` set in `openclaw/clawdex` Actions secrets
|
||||
|
||||
## 1) Verify build + tests
|
||||
|
||||
```sh
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.1 run
|
||||
go test -count=1 ./... -coverprofile=coverage.out
|
||||
go tool cover -func=coverage.out | tail -n 1
|
||||
go test -count=1 -race ./...
|
||||
go build -o /tmp/clawdex ./cmd/clawdex
|
||||
goreleaser release --snapshot --clean --skip=publish
|
||||
gh run list -L 5 --branch main
|
||||
```
|
||||
|
||||
Coverage floor: `90%+`
|
||||
|
||||
## 2) Update changelog
|
||||
|
||||
Add a new section in `CHANGELOG.md`.
|
||||
|
||||
Example:
|
||||
|
||||
- `## 0.2.0 - 2026-05-08`
|
||||
|
||||
## 3) Commit, tag, push
|
||||
|
||||
```sh
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git commit -am "release: vX.Y.Z"
|
||||
git tag -a vX.Y.Z -m "Release X.Y.Z"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
## 4) Verify GitHub release assets
|
||||
|
||||
The tag push triggers `.github/workflows/release.yml`.
|
||||
|
||||
```sh
|
||||
gh run list -L 5 --workflow release.yml
|
||||
gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Confirm assets exist for:
|
||||
|
||||
- `darwin_amd64`
|
||||
- `darwin_arm64`
|
||||
- `linux_amd64`
|
||||
- `linux_arm64`
|
||||
- `windows_amd64`
|
||||
- `windows_arm64`
|
||||
|
||||
## 5) Verify Homebrew tap update
|
||||
|
||||
The release workflow dispatches the tap updater after GoReleaser succeeds. The
|
||||
tap updater rewrites `Formula/clawdex.rb` with the release archive checksums.
|
||||
|
||||
```sh
|
||||
gh run list --repo steipete/homebrew-tap --workflow update-formula.yml -L 5
|
||||
brew update
|
||||
brew reinstall steipete/tap/clawdex
|
||||
clawdex --version
|
||||
brew test steipete/tap/clawdex
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Build-time version stamping comes from `-X github.com/openclaw/clawdex/internal/cli.Version={{ .Version }}`
|
||||
- If release workflow needs a rerun:
|
||||
|
||||
```sh
|
||||
gh workflow run release.yml -f tag=vX.Y.Z
|
||||
```
|
||||
12
go.mod
Normal file
12
go.mod
Normal file
@ -0,0 +1,12 @@
|
||||
module github.com/openclaw/clawdex
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.15.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/openclaw/crawlkit v0.5.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
18
go.sum
Normal file
18
go.sum
Normal file
@ -0,0 +1,18 @@
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
|
||||
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/openclaw/crawlkit v0.5.0 h1:sVqIbQ5v6LiOf+NXcVj93UhfoaJqMbBlrd1lU6uhO9M=
|
||||
github.com/openclaw/crawlkit v0.5.0/go.mod h1:/AI8o/DeRqXPZJPHq/9mGUjNzLPskm/wTjikRPxEdHY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
94
internal/apple/apple.go
Normal file
94
internal/apple/apple.go
Normal file
@ -0,0 +1,94 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
Identifier string `json:"identifier"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
FullName string `json:"full_name"`
|
||||
Emails []string `json:"emails"`
|
||||
Phones []string `json:"phones"`
|
||||
}
|
||||
|
||||
func (c Contact) Name() string {
|
||||
if strings.TrimSpace(c.FullName) != "" {
|
||||
return strings.TrimSpace(c.FullName)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{c.FirstName, c.LastName}, " "))
|
||||
}
|
||||
|
||||
func (c Contact) SourceContact() model.SourceContact {
|
||||
out := model.SourceContact{Source: "apple", ExternalID: c.Identifier, Name: c.Name()}
|
||||
for i, email := range c.Emails {
|
||||
if strings.TrimSpace(email) != "" {
|
||||
out.Emails = append(out.Emails, model.ContactValue{Value: email, Label: "other", Source: "apple", Primary: i == 0})
|
||||
}
|
||||
}
|
||||
for i, phone := range c.Phones {
|
||||
if strings.TrimSpace(phone) != "" {
|
||||
out.Phones = append(out.Phones, model.ContactValue{Value: phone, Label: "other", Source: "apple", Primary: i == 0})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ReadFile(path string) ([]Contact, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return Decode(f)
|
||||
}
|
||||
|
||||
func Decode(r io.Reader) ([]Contact, error) {
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var contacts []Contact
|
||||
if err := json.Unmarshal([]byte(trimmed), &contacts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contacts, nil
|
||||
}
|
||||
var contacts []Contact
|
||||
scanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var c Contact
|
||||
if err := json.Unmarshal([]byte(line), &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, scanner.Err()
|
||||
}
|
||||
|
||||
func ToSourceContacts(contacts []Contact) []model.SourceContact {
|
||||
out := make([]model.SourceContact, 0, len(contacts))
|
||||
for _, contact := range contacts {
|
||||
if strings.TrimSpace(contact.Name()) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, contact.SourceContact())
|
||||
}
|
||||
return out
|
||||
}
|
||||
55
internal/apple/apple_test.go
Normal file
55
internal/apple/apple_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeJSONArrayAndNDJSON(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`[{"identifier":"a1","full_name":"Ada Lovelace","emails":["ada@example.com"],"phones":["+1 555 0100"]}]`,
|
||||
"{\"identifier\":\"a1\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"emails\":[\"ada@example.com\"]}\n",
|
||||
} {
|
||||
contacts, err := Decode(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name() != "Ada Lovelace" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
src := contacts[0].SourceContact()
|
||||
if src.Source != "apple" || src.ExternalID != "a1" || src.Name != "Ada Lovelace" {
|
||||
t.Fatalf("source = %#v", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileAndToSourceContacts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "contacts.ndjson")
|
||||
if err := os.WriteFile(path, []byte("{\"full_name\":\"Ada\",\"emails\":[\"ada@example.com\"]}\n{\"phones\":[\"+1\"]}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := ToSourceContacts(contacts)
|
||||
if len(sources) != 1 || sources[0].Name != "Ada" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEmptyAndInvalid(t *testing.T) {
|
||||
contacts, err := Decode(strings.NewReader(" \n"))
|
||||
if err != nil || len(contacts) != 0 {
|
||||
t.Fatalf("contacts=%#v err=%v", contacts, err)
|
||||
}
|
||||
if _, err := Decode(strings.NewReader("{bad")); err == nil {
|
||||
t.Fatal("expected invalid json error")
|
||||
}
|
||||
if _, err := ReadFile(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected read file error")
|
||||
}
|
||||
}
|
||||
84
internal/apple/contacts_export.swift
Normal file
84
internal/apple/contacts_export.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
|
||||
struct ClawdexContact: Codable {
|
||||
let identifier: String
|
||||
let first_name: String
|
||||
let last_name: String
|
||||
let full_name: String
|
||||
let emails: [String]
|
||||
let phones: [String]
|
||||
}
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
break
|
||||
case .notDetermined:
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var granted = false
|
||||
var requestError: Error?
|
||||
store.requestAccess(for: .contacts) { ok, err in
|
||||
granted = ok
|
||||
requestError = err
|
||||
sem.signal()
|
||||
}
|
||||
_ = sem.wait(timeout: .now() + 60)
|
||||
if !granted {
|
||||
if let requestError {
|
||||
fail("Contacts access denied: \(requestError.localizedDescription)")
|
||||
}
|
||||
fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
fail("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
|
||||
@unknown default:
|
||||
fail("Contacts access is unavailable for this process.")
|
||||
}
|
||||
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
]
|
||||
|
||||
let request = CNContactFetchRequest(keysToFetch: keys)
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
do {
|
||||
try store.enumerateContacts(with: request) { contact, _ in
|
||||
let emails = contact.emailAddresses.map { String($0.value) }.filter { !$0.isEmpty }
|
||||
let phones = contact.phoneNumbers.map { $0.value.stringValue }.filter { !$0.isEmpty }
|
||||
guard !emails.isEmpty || !phones.isEmpty else { return }
|
||||
|
||||
var fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||
if fullName.isEmpty {
|
||||
fullName = contact.organizationName
|
||||
}
|
||||
guard !fullName.isEmpty else { return }
|
||||
|
||||
let row = ClawdexContact(
|
||||
identifier: contact.identifier,
|
||||
first_name: contact.givenName,
|
||||
last_name: contact.familyName,
|
||||
full_name: fullName,
|
||||
emails: emails,
|
||||
phones: phones
|
||||
)
|
||||
if let data = try? encoder.encode(row),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fail("Failed to enumerate Contacts: \(error.localizedDescription)")
|
||||
}
|
||||
57
internal/apple/darwin.go
Normal file
57
internal/apple/darwin.go
Normal file
@ -0,0 +1,57 @@
|
||||
//go:build darwin
|
||||
|
||||
package apple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//go:embed contacts_export.swift
|
||||
var contactsExportSwift string
|
||||
|
||||
func ReadSystem(ctx context.Context) ([]Contact, error) {
|
||||
dir, err := os.MkdirTemp("", "clawdex-contacts-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(dir) }()
|
||||
|
||||
script := filepath.Join(dir, "contacts-export.swift")
|
||||
if err := os.WriteFile(script, []byte(contactsExportSwift), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := runSwiftContacts(ctx, script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Decode(bytes.NewReader(out))
|
||||
}
|
||||
|
||||
var runSwiftContacts = func(ctx context.Context, script string) ([]byte, error) {
|
||||
cmd := swiftCommand(ctx, script)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return nil, fmt.Errorf("read macOS Contacts: %s", string(ee.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("run swift Contacts helper: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func swiftCommand(ctx context.Context, script string) *exec.Cmd {
|
||||
if path, err := exec.LookPath("swift"); err == nil {
|
||||
// #nosec G204 -- swift is resolved from PATH and the generated script is passed as an argument.
|
||||
return exec.CommandContext(ctx, path, script)
|
||||
}
|
||||
// #nosec G204 -- xcrun is fixed and the generated script is passed as an argument.
|
||||
return exec.CommandContext(ctx, "xcrun", "swift", script)
|
||||
}
|
||||
46
internal/apple/darwin_test.go
Normal file
46
internal/apple/darwin_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
//go:build darwin
|
||||
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSystemUsesSwiftRunner(t *testing.T) {
|
||||
orig := runSwiftContacts
|
||||
t.Cleanup(func() { runSwiftContacts = orig })
|
||||
runSwiftContacts = func(ctx context.Context, script string) ([]byte, error) {
|
||||
if !strings.HasSuffix(script, "contacts-export.swift") {
|
||||
t.Fatalf("script = %s", script)
|
||||
}
|
||||
return []byte(`{"identifier":"a1","full_name":"Ada","emails":["ada@example.com"]}` + "\n"), nil
|
||||
}
|
||||
contacts, err := ReadSystem(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name() != "Ada" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSystemPropagatesSwiftError(t *testing.T) {
|
||||
orig := runSwiftContacts
|
||||
t.Cleanup(func() { runSwiftContacts = orig })
|
||||
runSwiftContacts = func(context.Context, string) ([]byte, error) {
|
||||
return nil, errors.New("denied")
|
||||
}
|
||||
if _, err := ReadSystem(t.Context()); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwiftCommand(t *testing.T) {
|
||||
cmd := swiftCommand(t.Context(), "/tmp/test.swift")
|
||||
if cmd == nil || len(cmd.Args) == 0 {
|
||||
t.Fatalf("cmd = %#v", cmd)
|
||||
}
|
||||
}
|
||||
12
internal/apple/other.go
Normal file
12
internal/apple/other.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build !darwin
|
||||
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func ReadSystem(context.Context) ([]Contact, error) {
|
||||
return nil, errors.New("apple contacts are only supported on macOS; use vCard import/export fallback on this platform")
|
||||
}
|
||||
12
internal/apple/other_test.go
Normal file
12
internal/apple/other_test.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build !darwin
|
||||
|
||||
package apple
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReadSystemUnsupported(t *testing.T) {
|
||||
_, err := ReadSystem(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported error")
|
||||
}
|
||||
}
|
||||
133
internal/birdclaw/birdclaw.go
Normal file
133
internal/birdclaw/birdclaw.go
Normal file
@ -0,0 +1,133 @@
|
||||
package birdclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
DBPath string
|
||||
Binary string
|
||||
}
|
||||
|
||||
type dmRow struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Title string `json:"title"`
|
||||
Messages int `json:"messages"`
|
||||
FirstMessage string `json:"first_message"`
|
||||
LastMessage string `json:"last_message"`
|
||||
}
|
||||
|
||||
func (a Adapter) ListDMContacts(ctx context.Context, minMessages int) ([]model.SourceContact, error) {
|
||||
if minMessages < 1 {
|
||||
minMessages = 1
|
||||
}
|
||||
dbPath, err := resolveDBPath(a.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary := strings.TrimSpace(a.Binary)
|
||||
if binary == "" {
|
||||
binary = "sqlite3"
|
||||
}
|
||||
query := fmt.Sprintf(dmQuery, minMessages)
|
||||
// #nosec G204 -- sqlite3 is a configured binary and all arguments are passed without a shell.
|
||||
cmd := exec.CommandContext(ctx, binary, "-json", dbPath, query)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, sqliteErr(err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []dmRow
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.SourceContact, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := firstNonEmpty(row.DisplayName, row.Title, row.Handle)
|
||||
if name == "" || row.ConversationID == "" {
|
||||
continue
|
||||
}
|
||||
accounts := map[string][]string{"x": {"dm:" + row.ConversationID}}
|
||||
if row.Handle != "" {
|
||||
accounts["x"] = append(accounts["x"], "@"+strings.TrimPrefix(row.Handle, "@"))
|
||||
}
|
||||
if row.ProfileID != "" {
|
||||
accounts["x"] = append(accounts["x"], "user:"+row.ProfileID)
|
||||
}
|
||||
out = append(out, model.SourceContact{
|
||||
Source: "x",
|
||||
ExternalID: row.ConversationID,
|
||||
Name: name,
|
||||
Tags: []string{"x", "dm"},
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveDBPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, ".birdclaw", "birdclaw.sqlite")
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func sqliteErr(err error) error {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("birdclaw sqlite query: %s", strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const dmQuery = `
|
||||
select
|
||||
c.id as conversation_id,
|
||||
c.participant_profile_id as profile_id,
|
||||
coalesce(p.handle, '') as handle,
|
||||
coalesce(p.display_name, '') as display_name,
|
||||
c.title as title,
|
||||
count(m.id) as messages,
|
||||
min(m.created_at) as first_message,
|
||||
max(m.created_at) as last_message
|
||||
from dm_conversations c
|
||||
join dm_messages m on m.conversation_id = c.id
|
||||
left join profiles p on p.id = c.participant_profile_id
|
||||
group by c.id, c.participant_profile_id, p.handle, p.display_name, c.title
|
||||
having count(m.id) > %d
|
||||
order by messages desc, lower(coalesce(nullif(p.display_name, ''), nullif(c.title, ''), p.handle));
|
||||
`
|
||||
84
internal/birdclaw/birdclaw_test.go
Normal file
84
internal/birdclaw/birdclaw_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package birdclaw
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListDMContactsViaSQLiteAdapter(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
script := `#!/bin/sh
|
||||
case "$*" in
|
||||
*'count(m.id) > 4'*) ;;
|
||||
*) echo "missing threshold" >&2; exit 2 ;;
|
||||
esac
|
||||
cat <<'JSON'
|
||||
[{"conversation_id":"1-2","profile_id":"2","handle":"ada","display_name":"Ada Lovelace","title":"ada","messages":5}]
|
||||
JSON
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := (Adapter{DBPath: "~/birdclaw.sqlite", Binary: bin}).ListDMContacts(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name != "Ada Lovelace" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
accounts := contacts[0].Accounts["x"]
|
||||
if len(accounts) != 3 || accounts[1] != "@ada" || accounts[2] != "user:2" {
|
||||
t.Fatalf("accounts = %#v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDMContactsFallbacksAndFilters(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
cat <<'JSON'
|
||||
[{"conversation_id":"c1","profile_id":"","handle":"handle","display_name":"","title":"","messages":8},{"conversation_id":"","handle":"skip","messages":8},{"conversation_id":"c2","handle":"","display_name":"","title":" ","messages":8}]
|
||||
JSON
|
||||
`), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), -1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name != "handle" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
empty := filepath.Join(t.TempDir(), "sqlite3-empty")
|
||||
if err := os.WriteFile(empty, []byte("#!/bin/sh\nprintf ' '\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err = (Adapter{Binary: empty}).ListDMContacts(t.Context(), 4)
|
||||
if err != nil || len(contacts) != 0 {
|
||||
t.Fatalf("contacts=%#v err=%v", contacts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDMContactsErrors(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), 4); err == nil || !strings.Contains(err.Error(), "locked") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
bad := filepath.Join(t.TempDir(), "sqlite3-bad")
|
||||
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho not-json\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (Adapter{Binary: bad}).ListDMContacts(t.Context(), 4); err == nil {
|
||||
t.Fatal("expected json error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", " ", "x"); got != "x" {
|
||||
t.Fatalf("got = %q", got)
|
||||
}
|
||||
}
|
||||
704
internal/cli/cli.go
Normal file
704
internal/cli/cli.go
Normal file
@ -0,0 +1,704 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/openclaw/clawdex/internal/apple"
|
||||
"github.com/openclaw/clawdex/internal/birdclaw"
|
||||
"github.com/openclaw/clawdex/internal/discrawl"
|
||||
"github.com/openclaw/clawdex/internal/google"
|
||||
"github.com/openclaw/clawdex/internal/index"
|
||||
"github.com/openclaw/clawdex/internal/markdown"
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
"github.com/openclaw/clawdex/internal/repo"
|
||||
"github.com/openclaw/clawdex/internal/vcard"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
type CLI struct {
|
||||
Config string `name:"config" help:"Config path" env:"CLAWDEX_CONFIG"`
|
||||
Repo string `name:"repo" help:"Contacts data repo path" env:"CLAWDEX_REPO"`
|
||||
JSON bool `name:"json" help:"Write JSON to stdout"`
|
||||
Plain bool `name:"plain" help:"Write stable plain text to stdout"`
|
||||
DryRun bool `name:"dry-run" short:"n" help:"Preview changes without writing"`
|
||||
NoInput bool `name:"no-input" help:"Never prompt"`
|
||||
Verbose bool `name:"verbose" short:"v" help:"Verbose diagnostics"`
|
||||
|
||||
Version kong.VersionFlag `name:"version" help:"Print version and exit"`
|
||||
|
||||
Init InitCmd `cmd:"" help:"Initialize a contacts data repo"`
|
||||
ConfigC ConfigCmd `cmd:"" name:"config" help:"Show or edit clawdex config"`
|
||||
Person PersonCmd `cmd:"" help:"Manage people"`
|
||||
Note NoteCmd `cmd:"" help:"Manage notes"`
|
||||
Timeline TimelineCmd `cmd:"" help:"Show person timeline"`
|
||||
Search SearchCmd `cmd:"" help:"Search people and notes"`
|
||||
Import ImportCmd `cmd:"" help:"Import contacts into local markdown"`
|
||||
Sync SyncCmd `cmd:"" help:"Preview sync with address books"`
|
||||
Export ExportCmd `cmd:"" help:"Export contacts"`
|
||||
Git GitCmd `cmd:"" help:"Run data repo git helpers"`
|
||||
Doctor DoctorCmd `cmd:"" help:"Check repo health"`
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
ctx context.Context
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
root *CLI
|
||||
configPath string
|
||||
cfg repo.Config
|
||||
repo repo.Repo
|
||||
store index.Store
|
||||
}
|
||||
|
||||
func Execute(args []string, stdout, stderr io.Writer) error {
|
||||
var root CLI
|
||||
parser, err := kong.New(&root,
|
||||
kong.Name("clawdex"),
|
||||
kong.Description("Personal contact index backed by markdown and private Git."),
|
||||
kong.UsageOnError(),
|
||||
kong.Writers(stdout, stderr),
|
||||
kong.Vars{"version": Version},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kctx, err := parser.Parse(args)
|
||||
if err != nil {
|
||||
return usageErr{err}
|
||||
}
|
||||
configPath := repo.ResolveConfigPath(root.Config)
|
||||
cfg, err := repo.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoPath, err := repo.ResolveRepoPath(root.Repo, cfg)
|
||||
if err != nil {
|
||||
repoPath = cfg.RepoPath
|
||||
}
|
||||
r := &Runtime{
|
||||
ctx: context.Background(),
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
root: &root,
|
||||
configPath: configPath,
|
||||
cfg: cfg,
|
||||
repo: repo.Open(repoPath, cfg),
|
||||
}
|
||||
r.store = index.New(r.repo)
|
||||
kctx.Bind(r)
|
||||
if err := kctx.Run(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var usage usageErr
|
||||
if errors.As(err, &usage) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
type usageErr struct{ error }
|
||||
|
||||
type InitCmd struct {
|
||||
Dir string `arg:"" optional:"" help:"Contacts data repo directory"`
|
||||
Remote string `name:"remote" help:"Git remote for contacts backup"`
|
||||
NoConfig bool `name:"no-config" help:"Do not write app config"`
|
||||
}
|
||||
|
||||
func (c *InitCmd) Run(r *Runtime) error {
|
||||
cfg := r.cfg
|
||||
if c.Dir != "" {
|
||||
cfg.RepoPath = c.Dir
|
||||
}
|
||||
if c.Remote != "" {
|
||||
cfg.Git.Remote = c.Remote
|
||||
}
|
||||
cfg.Normalize()
|
||||
dataRepo := repo.Open(cfg.RepoPath, cfg)
|
||||
if err := dataRepo.Init(r.ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.NoConfig {
|
||||
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.print(map[string]any{"repo_path": cfg.RepoPath, "remote": cfg.Git.Remote, "config_path": r.configPath})
|
||||
}
|
||||
|
||||
type ConfigCmd struct {
|
||||
Show ConfigShowCmd `cmd:"" default:"1" help:"Show config"`
|
||||
Set ConfigSetCmd `cmd:"" help:"Set config value"`
|
||||
}
|
||||
|
||||
type ConfigShowCmd struct{}
|
||||
|
||||
func (c *ConfigShowCmd) Run(r *Runtime) error {
|
||||
return r.print(r.cfg)
|
||||
}
|
||||
|
||||
type ConfigSetCmd struct {
|
||||
Key string `arg:"" help:"Config key"`
|
||||
Value string `arg:"" help:"Config value"`
|
||||
}
|
||||
|
||||
func (c *ConfigSetCmd) Run(r *Runtime) error {
|
||||
cfg := r.cfg
|
||||
switch c.Key {
|
||||
case "repo_path":
|
||||
cfg.RepoPath = c.Value
|
||||
case "git.remote":
|
||||
cfg.Git.Remote = c.Value
|
||||
case "git.branch":
|
||||
cfg.Git.Branch = c.Value
|
||||
case "google.default_account":
|
||||
cfg.Google.DefaultAccount = c.Value
|
||||
default:
|
||||
return usageErr{fmt.Errorf("unsupported config key %q", c.Key)}
|
||||
}
|
||||
if r.root.DryRun {
|
||||
return r.print(cfg)
|
||||
}
|
||||
if err := repo.WriteConfig(r.configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(map[string]any{"config_path": r.configPath, "set": c.Key})
|
||||
}
|
||||
|
||||
type PersonCmd struct {
|
||||
Add PersonAddCmd `cmd:"" help:"Add a person"`
|
||||
List PersonListCmd `cmd:"" help:"List people"`
|
||||
Show PersonShowCmd `cmd:"" help:"Show a person"`
|
||||
Edit PersonEditCmd `cmd:"" help:"Edit a person markdown file"`
|
||||
}
|
||||
|
||||
type PersonAddCmd struct {
|
||||
Name string `arg:"" help:"Person name"`
|
||||
Email []string `name:"email" short:"e" help:"Email address"`
|
||||
Phone []string `name:"phone" short:"p" help:"Phone number"`
|
||||
Tag []string `name:"tag" short:"t" help:"Tag"`
|
||||
}
|
||||
|
||||
func (c *PersonAddCmd) Run(r *Runtime) error {
|
||||
if err := r.repo.Require(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.root.DryRun {
|
||||
return r.print(map[string]any{"would_create": c.Name})
|
||||
}
|
||||
p, err := r.store.AddPerson(c.Name, c.Email, c.Phone, c.Tag, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.printPerson(p)
|
||||
}
|
||||
|
||||
type PersonListCmd struct {
|
||||
Query string `name:"query" short:"q" help:"Filter query"`
|
||||
}
|
||||
|
||||
func (c *PersonListCmd) Run(r *Runtime) error {
|
||||
people, err := r.store.People()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Query != "" {
|
||||
filtered := people[:0]
|
||||
q := strings.ToLower(c.Query)
|
||||
for _, p := range people {
|
||||
if strings.Contains(strings.ToLower(p.Name+" "+p.ID+" "+strings.Join(p.Tags, " ")), q) {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
people = filtered
|
||||
}
|
||||
return r.printPeople(people)
|
||||
}
|
||||
|
||||
type PersonShowCmd struct {
|
||||
Query string `arg:"" help:"ID, name, email, or phone"`
|
||||
}
|
||||
|
||||
func (c *PersonShowCmd) Run(r *Runtime) error {
|
||||
p, err := r.store.FindPerson(c.Query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.printPerson(p)
|
||||
}
|
||||
|
||||
type PersonEditCmd struct {
|
||||
Query string `arg:"" help:"ID, name, email, or phone"`
|
||||
}
|
||||
|
||||
func (c *PersonEditCmd) Run(r *Runtime) error {
|
||||
p, err := r.store.FindPerson(c.Query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
if editor == "" {
|
||||
editor = "code"
|
||||
}
|
||||
// #nosec G204,G702 -- EDITOR is a deliberate user-controlled executable; no shell is involved.
|
||||
cmd := exec.CommandContext(r.ctx, editor, p.Path)
|
||||
cmd.Stdout = r.stdout
|
||||
cmd.Stderr = r.stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type NoteCmd struct {
|
||||
Add NoteAddCmd `cmd:"" help:"Add a note"`
|
||||
List NoteListCmd `cmd:"" help:"List notes"`
|
||||
}
|
||||
|
||||
type NoteAddCmd struct {
|
||||
Person string `arg:"" help:"Person query"`
|
||||
Kind string `name:"kind" required:"" help:"Note kind"`
|
||||
Source string `name:"source" required:"" help:"Note source"`
|
||||
Text string `name:"text" help:"Note body"`
|
||||
OccurredAt string `name:"occurred-at" help:"Occurrence time"`
|
||||
Topic []string `name:"topic" help:"Topic"`
|
||||
}
|
||||
|
||||
func (c *NoteAddCmd) Run(r *Runtime) error {
|
||||
if c.Text == "" {
|
||||
return usageErr{errors.New("--text is required")}
|
||||
}
|
||||
occurredAt, err := parseOptionalTime(c.OccurredAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n := markdown.NewNote("", c.Kind, c.Source, c.Text, occurredAt, time.Now(), c.Topic)
|
||||
if r.root.DryRun {
|
||||
return r.print(n)
|
||||
}
|
||||
n, err = r.store.AddNote(c.Person, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(n)
|
||||
}
|
||||
|
||||
type NoteListCmd struct {
|
||||
Person string `arg:"" help:"Person query"`
|
||||
}
|
||||
|
||||
func (c *NoteListCmd) Run(r *Runtime) error {
|
||||
notes, err := r.store.Notes(c.Person)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(notes)
|
||||
}
|
||||
|
||||
type TimelineCmd struct {
|
||||
Person string `arg:"" help:"Person query"`
|
||||
}
|
||||
|
||||
func (c *TimelineCmd) Run(r *Runtime) error {
|
||||
notes, err := r.store.Notes(c.Person)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.printTimeline(notes)
|
||||
}
|
||||
|
||||
type SearchCmd struct {
|
||||
Query string `arg:"" help:"Search query"`
|
||||
}
|
||||
|
||||
func (c *SearchCmd) Run(r *Runtime) error {
|
||||
hits, err := r.store.Search(c.Query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(hits)
|
||||
}
|
||||
|
||||
type ImportCmd struct {
|
||||
Apple ImportAppleCmd `cmd:"" help:"Import Apple Contacts into local markdown"`
|
||||
Birdclaw ImportBirdclawCmd `cmd:"" help:"Import X/Twitter DM contacts from local birdclaw archive"`
|
||||
Google ImportGoogleCmd `cmd:"" help:"Import Google Contacts into local markdown"`
|
||||
Discrawl ImportDiscrawlCmd `cmd:"" help:"Import Discord DM contacts from local discrawl archive"`
|
||||
}
|
||||
|
||||
type ImportAppleCmd struct {
|
||||
Input string `name:"input" help:"JSON/NDJSON contact file instead of macOS Contacts"`
|
||||
}
|
||||
|
||||
func (c *ImportAppleCmd) Run(r *Runtime) error {
|
||||
var contacts []apple.Contact
|
||||
var err error
|
||||
if c.Input != "" {
|
||||
contacts, err = apple.ReadFile(c.Input)
|
||||
} else {
|
||||
contacts, err = apple.ReadSystem(r.ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := r.store.ImportContacts("apple", apple.ToSourceContacts(contacts), r.root.DryRun, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(changes)
|
||||
}
|
||||
|
||||
type ImportGoogleCmd struct {
|
||||
Account string `name:"account" help:"Google account email"`
|
||||
}
|
||||
|
||||
func (c *ImportGoogleCmd) Run(r *Runtime) error {
|
||||
account := c.Account
|
||||
if account == "" {
|
||||
account = r.cfg.Google.DefaultAccount
|
||||
}
|
||||
contacts, err := (google.GogAdapter{}).ListContacts(r.ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := r.store.ImportContacts("google", contacts, r.root.DryRun, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(changes)
|
||||
}
|
||||
|
||||
type ImportDiscrawlCmd struct {
|
||||
DBPath string `name:"db" help:"discrawl SQLite database path" default:"~/.discrawl/discrawl.db"`
|
||||
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
|
||||
}
|
||||
|
||||
type ImportBirdclawCmd struct {
|
||||
DBPath string `name:"db" help:"birdclaw SQLite database path" default:"~/.birdclaw/birdclaw.sqlite"`
|
||||
MinMessages int `name:"min-messages" help:"Import DMs with more than this many messages" default:"4"`
|
||||
}
|
||||
|
||||
func (c *ImportBirdclawCmd) Run(r *Runtime) error {
|
||||
contacts, err := (birdclaw.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := r.store.ImportContacts("x", contacts, r.root.DryRun, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(changes)
|
||||
}
|
||||
|
||||
func (c *ImportDiscrawlCmd) Run(r *Runtime) error {
|
||||
contacts, err := (discrawl.Adapter{DBPath: c.DBPath}).ListDMContacts(r.ctx, c.MinMessages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := r.store.ImportContacts("discord", contacts, r.root.DryRun, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(changes)
|
||||
}
|
||||
|
||||
type SyncCmd struct {
|
||||
Apple SyncAppleCmd `cmd:"" help:"Preview Apple Contacts sync"`
|
||||
Google SyncGoogleCmd `cmd:"" help:"Preview Google Contacts sync"`
|
||||
}
|
||||
|
||||
type SyncAppleCmd struct{}
|
||||
|
||||
func (c *SyncAppleCmd) Run(r *Runtime) error {
|
||||
return r.print(map[string]any{"dry_run": true, "status": "remote writes not implemented yet; use import apple for local markdown projection"})
|
||||
}
|
||||
|
||||
type SyncGoogleCmd struct {
|
||||
Account string `name:"account" help:"Google account email"`
|
||||
}
|
||||
|
||||
func (c *SyncGoogleCmd) Run(r *Runtime) error {
|
||||
return r.print(map[string]any{"dry_run": true, "account": firstNonEmpty(c.Account, r.cfg.Google.DefaultAccount), "status": "remote writes not implemented yet; use import google for local markdown projection"})
|
||||
}
|
||||
|
||||
type ExportCmd struct {
|
||||
VCard ExportVCardCmd `cmd:"" name:"vcard" help:"Export vCard"`
|
||||
}
|
||||
|
||||
type ExportVCardCmd struct {
|
||||
Person string `name:"person" help:"Person query"`
|
||||
All bool `name:"all" help:"Export all people"`
|
||||
Out string `name:"out" short:"o" required:"" help:"Output .vcf path, or - for stdout"`
|
||||
}
|
||||
|
||||
func (c *ExportVCardCmd) Run(r *Runtime) error {
|
||||
var people []model.Person
|
||||
switch {
|
||||
case c.All:
|
||||
var err error
|
||||
people, err = r.store.People()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case c.Person != "":
|
||||
p, err := r.store.FindPerson(c.Person)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
people = []model.Person{p}
|
||||
default:
|
||||
return usageErr{errors.New("provide --person or --all")}
|
||||
}
|
||||
if c.Out == "-" {
|
||||
return vcard.Write(r.stdout, people)
|
||||
}
|
||||
f, err := os.Create(c.Out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := vcard.Write(f, people); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(map[string]any{"exported": len(people), "out": c.Out})
|
||||
}
|
||||
|
||||
type GitCmd struct {
|
||||
Status GitStatusCmd `cmd:"" default:"1" help:"Show git status"`
|
||||
Pull GitPullCmd `cmd:"" help:"Pull data repo"`
|
||||
Push GitPushCmd `cmd:"" help:"Push data repo"`
|
||||
Commit GitCommitCmd `cmd:"" help:"Commit data repo changes"`
|
||||
}
|
||||
|
||||
type GitStatusCmd struct{}
|
||||
|
||||
func (c *GitStatusCmd) Run(r *Runtime) error {
|
||||
// #nosec G204 -- git is fixed and repo path is passed as a plain argument.
|
||||
cmd := exec.CommandContext(r.ctx, "git", "-C", r.repo.Path, "status", "--short", "--branch")
|
||||
cmd.Stdout = r.stdout
|
||||
cmd.Stderr = r.stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type GitPullCmd struct{}
|
||||
|
||||
func (c *GitPullCmd) Run(r *Runtime) error {
|
||||
return r.repo.Pull(r.ctx)
|
||||
}
|
||||
|
||||
type GitPushCmd struct{}
|
||||
|
||||
func (c *GitPushCmd) Run(r *Runtime) error {
|
||||
return r.repo.Push(r.ctx)
|
||||
}
|
||||
|
||||
type GitCommitCmd struct {
|
||||
Message string `name:"message" short:"m" help:"Commit message" default:"sync: update clawdex contacts"`
|
||||
}
|
||||
|
||||
func (c *GitCommitCmd) Run(r *Runtime) error {
|
||||
committed, err := r.repo.Commit(r.ctx, c.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.print(map[string]any{"committed": committed})
|
||||
}
|
||||
|
||||
type DoctorCmd struct {
|
||||
Repair bool `name:"repair" help:"Repair damaged markdown frontmatter"`
|
||||
}
|
||||
|
||||
func (c *DoctorCmd) Run(r *Runtime) error {
|
||||
store := r.store
|
||||
if c.Repair {
|
||||
store.Repo.Config.Repair.AutoRepair = false
|
||||
}
|
||||
people, err := store.People()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirty, _ := r.repo.Dirty(r.ctx)
|
||||
result := map[string]any{
|
||||
"config_path": r.configPath,
|
||||
"repo_path": r.repo.Path,
|
||||
"remote": r.cfg.Git.Remote,
|
||||
"people": len(people),
|
||||
"git_dirty": dirty,
|
||||
}
|
||||
if c.Repair {
|
||||
var repaired int
|
||||
for _, p := range people {
|
||||
loaded, report, err := markdown.ReadPerson(p.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if report.Needed {
|
||||
repaired++
|
||||
if !r.root.DryRun {
|
||||
if err := markdown.RepairPerson(p.Path, r.repo.RepairDir(), loaded, report, r.cfg.Repair.BackupBeforeRepair); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result["repaired"] = repaired
|
||||
result["dry_run"] = r.root.DryRun
|
||||
}
|
||||
return r.print(result)
|
||||
}
|
||||
|
||||
func (r *Runtime) print(value any) error {
|
||||
if r.root.JSON {
|
||||
enc := json.NewEncoder(r.stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(value)
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(v))
|
||||
for key := range v {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s: %v\n", key, v[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case model.Note:
|
||||
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", v.ID, v.Kind, v.Source, v.Path)
|
||||
return err
|
||||
case []model.Note:
|
||||
return r.printTimeline(v)
|
||||
case []model.SearchHit:
|
||||
return r.printHits(v)
|
||||
case []model.ImportChange:
|
||||
for _, change := range v {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", change.Action, change.Name, change.PersonID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
enc := json.NewEncoder(r.stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) printPerson(p model.Person) error {
|
||||
if r.root.JSON {
|
||||
return r.print(p)
|
||||
}
|
||||
if r.root.Plain {
|
||||
_, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path)
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(r.stdout, "id: %s\nname: %s\npath: %s\n", p.ID, p.Name, p.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, email := range p.Emails {
|
||||
if _, err := fmt.Fprintf(r.stdout, "email: %s\n", email.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, phone := range p.Phones {
|
||||
if _, err := fmt.Fprintf(r.stdout, "phone: %s\n", phone.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) printPeople(people []model.Person) error {
|
||||
if r.root.JSON {
|
||||
return r.print(people)
|
||||
}
|
||||
for _, p := range people {
|
||||
if r.root.Plain {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, p.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\n", p.ID, p.Name, firstEmail(p)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) printTimeline(notes []model.Note) error {
|
||||
if r.root.JSON {
|
||||
return r.print(notes)
|
||||
}
|
||||
for _, n := range notes {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", n.OccurredAt.Format(time.RFC3339), n.Kind, n.Source, strings.ReplaceAll(n.Body, "\n", " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) printHits(hits []model.SearchHit) error {
|
||||
for _, hit := range hits {
|
||||
if r.root.Plain {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.ID, hit.Name, hit.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", hit.Kind, hit.Name, hit.Snippet, hit.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstEmail(p model.Person) string {
|
||||
if len(p.Emails) == 0 {
|
||||
return ""
|
||||
}
|
||||
return p.Emails[0].Value
|
||||
}
|
||||
|
||||
func parseOptionalTime(value string) (time.Time, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04", "2006-01-02"} {
|
||||
t, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, usageErr{fmt.Errorf("invalid time %q", value)}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
557
internal/cli/cli_test.go
Normal file
557
internal/cli/cli_test.go
Normal file
@ -0,0 +1,557 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func TestExecuteEndToEndLocalCommands(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
run := func(args ...string) string {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
full := append([]string{"--config", cfg}, args...)
|
||||
if err := Execute(full, &out, &errOut); err != nil {
|
||||
t.Fatalf("Execute(%v): %v stderr=%s stdout=%s", full, err, errOut.String(), out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
out := run("init", data, "--remote", "")
|
||||
if !strings.Contains(out, "repo_path:") {
|
||||
t.Fatalf("init out = %s", out)
|
||||
}
|
||||
out = run("person", "add", "Ada Lovelace", "--email", "ada@example.com", "--phone", "+1 555 0100", "--tag", "math")
|
||||
if !strings.Contains(out, "Ada Lovelace") {
|
||||
t.Fatalf("add out = %s", out)
|
||||
}
|
||||
out = run("person", "list", "--plain")
|
||||
if !strings.Contains(out, "Ada Lovelace") {
|
||||
t.Fatalf("list out = %s", out)
|
||||
}
|
||||
out = run("person", "show", "ada@example.com")
|
||||
if !strings.Contains(out, "email: ada@example.com") {
|
||||
t.Fatalf("show out = %s", out)
|
||||
}
|
||||
out = run("note", "add", "ada", "--kind", "dm", "--source", "manual", "--text", "Analytical engine")
|
||||
if !strings.Contains(out, "dm\tmanual") {
|
||||
t.Fatalf("note out = %s", out)
|
||||
}
|
||||
out = run("note", "list", "ada")
|
||||
if !strings.Contains(out, "Analytical engine") {
|
||||
t.Fatalf("notes out = %s", out)
|
||||
}
|
||||
out = run("timeline", "ada")
|
||||
if !strings.Contains(out, "Analytical engine") {
|
||||
t.Fatalf("timeline out = %s", out)
|
||||
}
|
||||
out = run("search", "engine", "--plain")
|
||||
if !strings.Contains(out, "note") {
|
||||
t.Fatalf("search out = %s", out)
|
||||
}
|
||||
vcardPath := filepath.Join(t.TempDir(), "contacts.vcf")
|
||||
out = run("export", "vcard", "--all", "-o", vcardPath)
|
||||
if !strings.Contains(out, "exported: 1") {
|
||||
t.Fatalf("export out = %s", out)
|
||||
}
|
||||
if data, err := os.ReadFile(vcardPath); err != nil || !strings.Contains(string(data), "BEGIN:VCARD") {
|
||||
t.Fatalf("vcard data=%q err=%v", data, err)
|
||||
}
|
||||
out = run("sync", "apple")
|
||||
if !strings.Contains(out, "remote writes not implemented") {
|
||||
t.Fatalf("sync out = %s", out)
|
||||
}
|
||||
out = run("sync", "google", "--account", "me@example.com")
|
||||
if !strings.Contains(out, "me@example.com") {
|
||||
t.Fatalf("sync google out = %s", out)
|
||||
}
|
||||
out = run("doctor")
|
||||
if !strings.Contains(out, "people: 1") {
|
||||
t.Fatalf("doctor out = %s", out)
|
||||
}
|
||||
out = run("git", "commit", "-m", "test: contacts")
|
||||
if !strings.Contains(out, "committed: true") {
|
||||
t.Fatalf("git commit out = %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteConfigJSONAndUsage(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "--json", "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatalf("init: %v %s", err, errOut.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json = %s err=%v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "config", "set", "git.branch", "main"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "--dry-run", "config", "set", "google.default_account", "me@example.com"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "me@example.com") {
|
||||
t.Fatalf("dry config = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "--json", "config", "show"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"branch": "main"`) {
|
||||
t.Fatalf("config = %s", out.String())
|
||||
}
|
||||
if err := Execute([]string{"--config", cfg, "config", "set", "nope", "x"}, &out, &errOut); err == nil || ExitCode(err) != 2 {
|
||||
t.Fatalf("expected usage err, got %v", err)
|
||||
}
|
||||
if err := Execute([]string{"--bogus"}, &out, &errOut); err == nil || ExitCode(err) != 2 {
|
||||
t.Fatalf("expected parse usage err, got %v", err)
|
||||
}
|
||||
badCfg := filepath.Join(t.TempDir(), "bad.toml")
|
||||
if err := os.WriteFile(badCfg, []byte("["), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Execute([]string{"--config", badCfg, "config"}, &out, &errOut); err == nil {
|
||||
t.Fatal("expected config parse error")
|
||||
}
|
||||
if ExitCode(nil) != 0 {
|
||||
t.Fatal("nil exit code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteImportAppleFromFileAndGoogleViaFakeGog(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(t.TempDir(), "apple.ndjson")
|
||||
if err := os.WriteFile(input, []byte("{\"identifier\":\"a1\",\"full_name\":\"Ada Apple\",\"emails\":[\"apple@example.com\"]}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "import", "apple", "--input", input}, &out, &errOut); err != nil {
|
||||
t.Fatalf("apple import: %v %s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "create\tAda Apple") {
|
||||
t.Fatalf("apple import out = %s", out.String())
|
||||
}
|
||||
fakeGog := writeFakeGog(t, `[{"resourceName":"people/g1","name":"Grace Google","email":"grace@example.com"}]`)
|
||||
t.Setenv("PATH", filepath.Dir(fakeGog)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "import", "google", "--account", "me@example.com"}, &out, &errOut); err != nil {
|
||||
t.Fatalf("google import: %v %s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "create\tGrace Google") {
|
||||
t.Fatalf("google import out = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "person", "show", "grace@example.com"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Grace Google") {
|
||||
t.Fatalf("show = %s", out.String())
|
||||
}
|
||||
fakeSQLite := writeFakeSQLite(t, `[{"channel_id":"dm1","name":"Discord Friend","messages":5,"counterpart_id":"user1"}]`)
|
||||
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "import", "discrawl", "--db", filepath.Join(t.TempDir(), "discrawl.db"), "--min-messages", "4"}, &out, &errOut); err != nil {
|
||||
t.Fatalf("discrawl import: %v %s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "create\tDiscord Friend") {
|
||||
t.Fatalf("discrawl import out = %s", out.String())
|
||||
}
|
||||
fakeBirdclaw := writeFakeSQLite(t, `[{"conversation_id":"1-2","profile_id":"2","handle":"bird","display_name":"Bird Person","messages":5}]`)
|
||||
t.Setenv("PATH", filepath.Dir(fakeBirdclaw)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "import", "birdclaw", "--db", filepath.Join(t.TempDir(), "birdclaw.sqlite"), "--min-messages", "4"}, &out, &errOut); err != nil {
|
||||
t.Fatalf("birdclaw import: %v %s", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "create\tBird Person") {
|
||||
t.Fatalf("birdclaw import out = %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGitStatusAndDryRun(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "--dry-run", "person", "add", "Dry Run"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "would_create: Dry Run") {
|
||||
t.Fatalf("dry run = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "git"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "No commits yet") {
|
||||
t.Fatalf("git status = %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteImportDiscrawlErrors(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeSQLite := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(fakeSQLite, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
if err := Execute([]string{"--config", cfg, "import", "discrawl", "--db", filepath.Join(t.TempDir(), "discrawl.db")}, &out, &errOut); err == nil {
|
||||
t.Fatal("expected discrawl import error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteImportBirdclawErrors(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeSQLite := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(fakeSQLite, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", filepath.Dir(fakeSQLite)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
if err := Execute([]string{"--config", cfg, "import", "birdclaw", "--db", filepath.Join(t.TempDir(), "birdclaw.sqlite")}, &out, &errOut); err == nil {
|
||||
t.Fatal("expected birdclaw import error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteJSONPlainAndStdoutBranches(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
must := func(args ...string) string {
|
||||
t.Helper()
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute(append([]string{"--config", cfg}, args...), &out, &errOut); err != nil {
|
||||
t.Fatalf("%v: %v stderr=%s", args, err, errOut.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
must("init", data, "--remote", "")
|
||||
must("person", "add", "Ada JSON", "--email", "json@example.com")
|
||||
must("person", "add", "Empty Email")
|
||||
if got := must("--json", "person", "show", "json@example.com"); !strings.Contains(got, `"name": "Ada JSON"`) {
|
||||
t.Fatalf("json show = %s", got)
|
||||
}
|
||||
if got := must("--json", "person", "list", "--query", "Ada"); !strings.Contains(got, `"Ada JSON"`) {
|
||||
t.Fatalf("json list = %s", got)
|
||||
}
|
||||
if got := must("--plain", "person", "show", "json@example.com"); !strings.Contains(got, "Ada JSON") {
|
||||
t.Fatalf("plain show = %s", got)
|
||||
}
|
||||
if got := must("--plain", "person", "list", "--query", "NoMatch"); got != "" {
|
||||
t.Fatalf("empty list = %s", got)
|
||||
}
|
||||
if got := must("person", "list", "--query", "Empty"); !strings.Contains(got, "Empty Email") {
|
||||
t.Fatalf("no-email list = %s", got)
|
||||
}
|
||||
must("note", "add", "json@example.com", "--kind", "call", "--source", "manual", "--text", "Call body", "--occurred-at", "2026-05-08 10:00")
|
||||
if got := must("--json", "note", "list", "json@example.com"); !strings.Contains(got, `"kind": "call"`) {
|
||||
t.Fatalf("json notes = %s", got)
|
||||
}
|
||||
if got := must("export", "vcard", "--person", "json@example.com", "-o", "-"); !strings.Contains(got, "BEGIN:VCARD") {
|
||||
t.Fatalf("stdout vcard = %s", got)
|
||||
}
|
||||
input := filepath.Join(t.TempDir(), "apple.ndjson")
|
||||
if err := os.WriteFile(input, []byte("{\"identifier\":\"a1\",\"full_name\":\"Dry Apple\",\"emails\":[\"dry@example.com\"]}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := must("--dry-run", "import", "apple", "--input", input); !strings.Contains(got, "create\tDry Apple") {
|
||||
t.Fatalf("dry import = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHelpersCoverPlainJSONAndWriteErrors(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
person := model.Person{
|
||||
ID: "person_1",
|
||||
Name: "Print Person",
|
||||
Path: "/tmp/person.md",
|
||||
Emails: []model.ContactValue{{Value: "print@example.com"}},
|
||||
}
|
||||
note := model.Note{
|
||||
ID: "note_1",
|
||||
Kind: "note",
|
||||
Source: "manual",
|
||||
OccurredAt: time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC),
|
||||
Body: "line one\nline two",
|
||||
}
|
||||
hit := model.SearchHit{Kind: "note", ID: "note_1", Name: "Print Person", Snippet: "line", Path: "/tmp/note.md"}
|
||||
|
||||
r := &Runtime{stdout: &out, root: &CLI{}}
|
||||
if err := r.printPeople([]model.Person{person}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "print@example.com") {
|
||||
t.Fatalf("people out = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := r.printTimeline([]model.Note{note}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out.String(), "\nline two") {
|
||||
t.Fatalf("timeline did not flatten body = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := r.printHits([]model.SearchHit{hit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "line") {
|
||||
t.Fatalf("hits out = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
r.root.Plain = true
|
||||
if err := r.printPeople([]model.Person{{ID: "person_2", Name: "No Email", Path: "/tmp/no.md"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.printHits([]model.SearchHit{hit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.root.JSON = true
|
||||
if err := r.printTimeline([]model.Note{note}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r.stdout = errWriter{}
|
||||
r.root.JSON = false
|
||||
if err := r.printPeople([]model.Person{person}); err == nil {
|
||||
t.Fatal("expected printPeople write error")
|
||||
}
|
||||
if err := r.printTimeline([]model.Note{note}); err == nil {
|
||||
t.Fatal("expected printTimeline write error")
|
||||
}
|
||||
if err := r.printHits([]model.SearchHit{hit}); err == nil {
|
||||
t.Fatal("expected printHits write error")
|
||||
}
|
||||
if err := r.printPerson(person); err == nil {
|
||||
t.Fatal("expected printPerson write error")
|
||||
}
|
||||
}
|
||||
|
||||
type errWriter struct{}
|
||||
|
||||
func (errWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
|
||||
func TestExecuteGitPushPullWithLocalRemote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config.toml")
|
||||
data := filepath.Join(dir, "contacts")
|
||||
remote := filepath.Join(dir, "remote.git")
|
||||
if err := os.Mkdir(remote, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runShell(t, remote, "git", "init", "--bare")
|
||||
var out, errOut bytes.Buffer
|
||||
for _, args := range [][]string{
|
||||
{"--config", cfg, "init", data, "--remote", remote},
|
||||
{"--config", cfg, "person", "add", "Ada Remote"},
|
||||
{"--config", cfg, "git", "commit", "-m", "test: remote"},
|
||||
{"--config", cfg, "git", "push"},
|
||||
{"--config", cfg, "git", "pull"},
|
||||
} {
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute(args, &out, &errOut); err != nil {
|
||||
t.Fatalf("%v: %v stderr=%s stdout=%s", args, err, errOut.String(), out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEditorExportPersonAndRepair(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Execute([]string{"--config", cfg, "person", "add", "Ada Edit", "--email", "edit@example.com"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editor := filepath.Join(t.TempDir(), "editor")
|
||||
if err := os.WriteFile(editor, []byte("#!/bin/sh\nprintf '%s' \"$1\" > \""+filepath.Join(t.TempDir(), "edited")+"\"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("EDITOR", editor)
|
||||
if err := Execute([]string{"--config", cfg, "person", "edit", "edit@example.com"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vcardPath := filepath.Join(t.TempDir(), "one.vcf")
|
||||
if err := Execute([]string{"--config", cfg, "export", "vcard", "--person", "edit@example.com", "-o", vcardPath}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
personPath := filepath.Join(data, "people", "ada-edit", "person.md")
|
||||
if err := os.WriteFile(personPath, []byte("---\nid: person_x\nname: Ada Edit\ntags: [broken\n---\n# Ada Edit\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "--dry-run", "doctor", "--repair"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "repaired: 1") {
|
||||
t.Fatalf("repair dry-run = %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "doctor", "--repair"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "repaired: 1") {
|
||||
t.Fatalf("repair = %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsageGuards(t *testing.T) {
|
||||
cfg, data := testPaths(t)
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--config", cfg, "note", "add", "nobody", "--kind", "note", "--source", "manual"},
|
||||
{"--config", cfg, "note", "add", "nobody", "--kind", "note", "--source", "manual", "--text", "x", "--occurred-at", "bad"},
|
||||
{"--config", cfg, "export", "vcard", "-o", filepath.Join(t.TempDir(), "x.vcf")},
|
||||
{"--config", cfg, "person", "show", "missing"},
|
||||
} {
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute(args, &out, &errOut); err == nil {
|
||||
t.Fatalf("expected error for %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmallCLIHelpers(t *testing.T) {
|
||||
if got := firstNonEmpty("", " ", "x"); got != "x" {
|
||||
t.Fatalf("firstNonEmpty = %q", got)
|
||||
}
|
||||
if got := firstNonEmpty("", " "); got != "" {
|
||||
t.Fatalf("firstNonEmpty empty = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteErrorBranchesAndNoConfigInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config.toml")
|
||||
data := filepath.Join(dir, "contacts")
|
||||
var out, errOut bytes.Buffer
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", "", "--no-config"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cfg); !os.IsNotExist(err) {
|
||||
t.Fatalf("config unexpectedly exists: %v", err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "person", "add", "No Repo"},
|
||||
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "person", "list"},
|
||||
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "export", "vcard", "--all", "-o", "-"},
|
||||
{"--config", cfg, "--repo", filepath.Join(dir, "missing"), "doctor"},
|
||||
} {
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute(args, &out, &errOut); err == nil {
|
||||
t.Fatalf("expected error for %v", args)
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute([]string{"--config", cfg, "init", data, "--remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Execute([]string{"--config", cfg, "config", "set", "repo_path", data}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Execute([]string{"--config", cfg, "config", "set", "git.remote", ""}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"--config", cfg, "note", "list", "missing"},
|
||||
{"--config", cfg, "timeline", "missing"},
|
||||
{"--config", cfg, "search", ""},
|
||||
{"--config", cfg, "export", "vcard", "--person", "missing", "-o", "-"},
|
||||
{"--config", cfg, "export", "vcard", "--all", "-o", filepath.Join(dir, "nope", "x.vcf")},
|
||||
{"--config", cfg, "import", "apple", "--input", filepath.Join(dir, "missing.ndjson")},
|
||||
} {
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
if err := Execute(args, &out, &errOut); err == nil {
|
||||
t.Fatalf("expected error for %v", args)
|
||||
}
|
||||
}
|
||||
fakeGog := writeFakeGogExit(t)
|
||||
t.Setenv("PATH", filepath.Dir(fakeGog)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
if err := Execute([]string{"--config", cfg, "import", "google"}, &out, &errOut); err == nil {
|
||||
t.Fatal("expected fake gog failure")
|
||||
}
|
||||
}
|
||||
|
||||
func testPaths(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return filepath.Join(dir, "config.toml"), filepath.Join(dir, "contacts")
|
||||
}
|
||||
|
||||
func writeFakeGog(t *testing.T, output string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
name := "gog"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "gog.bat"
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
script := "#!/bin/sh\nprintf '%s\\n' '" + output + "'\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeFakeGogExit(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gog")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\necho bad >&2\nexit 4\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeFakeSQLite(t *testing.T, output string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sqlite3")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\ncat <<'JSON'\n"+output+"\nJSON\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func runShell(t *testing.T, dir string, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), name, args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
||||
}
|
||||
}
|
||||
148
internal/discrawl/discrawl.go
Normal file
148
internal/discrawl/discrawl.go
Normal file
@ -0,0 +1,148 @@
|
||||
package discrawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
DBPath string
|
||||
Binary string
|
||||
}
|
||||
|
||||
type dmRow struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Name string `json:"name"`
|
||||
Messages int `json:"messages"`
|
||||
FirstMessage string `json:"first_message"`
|
||||
LastMessage string `json:"last_message"`
|
||||
CounterpartID string `json:"counterpart_id"`
|
||||
}
|
||||
|
||||
func (a Adapter) ListDMContacts(ctx context.Context, minMessages int) ([]model.SourceContact, error) {
|
||||
if minMessages < 1 {
|
||||
minMessages = 1
|
||||
}
|
||||
dbPath, err := resolveDBPath(a.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary := strings.TrimSpace(a.Binary)
|
||||
if binary == "" {
|
||||
binary = "sqlite3"
|
||||
}
|
||||
query := fmt.Sprintf(dmQuery, minMessages)
|
||||
// #nosec G204 -- sqlite3 is a configured binary and all arguments are passed without a shell.
|
||||
cmd := exec.CommandContext(ctx, binary, "-json", "file:"+dbPath+"?mode=ro&immutable=1", query)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, sqliteErr(err)
|
||||
}
|
||||
var rows []dmRow
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.SourceContact, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.Name)
|
||||
if name == "" || row.ChannelID == "" {
|
||||
continue
|
||||
}
|
||||
accounts := map[string][]string{"discord": {"channel:" + row.ChannelID}}
|
||||
if row.CounterpartID != "" {
|
||||
accounts["discord"] = append(accounts["discord"], "user:"+row.CounterpartID)
|
||||
}
|
||||
out = append(out, model.SourceContact{
|
||||
Source: "discord",
|
||||
ExternalID: row.ChannelID,
|
||||
Name: name,
|
||||
Tags: []string{"discord", "dm"},
|
||||
Accounts: accounts,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveDBPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, ".discrawl", "discrawl.db")
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
||||
}
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
|
||||
func sqliteErr(err error) error {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("discrawl sqlite query: %s", strings.TrimSpace(string(exitErr.Stderr)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const dmQuery = `
|
||||
with dm as (
|
||||
select
|
||||
c.id as channel_id,
|
||||
c.name as name,
|
||||
count(m.id) as messages,
|
||||
min(m.created_at) as first_message,
|
||||
max(m.created_at) as last_message
|
||||
from channels c
|
||||
join messages m on m.channel_id = c.id
|
||||
where c.guild_id = '@me' and c.kind = 'dm'
|
||||
group by c.id, c.name
|
||||
having count(m.id) > %d
|
||||
),
|
||||
self as (
|
||||
select m.author_id
|
||||
from channels c
|
||||
join messages m on m.channel_id = c.id
|
||||
where c.guild_id = '@me' and c.kind = 'dm' and m.author_id is not null
|
||||
group by m.author_id
|
||||
order by count(distinct c.id) desc, count(m.id) desc
|
||||
limit 1
|
||||
),
|
||||
author_counts as (
|
||||
select c.id as channel_id, m.author_id, count(m.id) as messages
|
||||
from channels c
|
||||
join messages m on m.channel_id = c.id
|
||||
where c.guild_id = '@me' and c.kind = 'dm' and m.author_id is not null
|
||||
group by c.id, m.author_id
|
||||
)
|
||||
select
|
||||
dm.channel_id,
|
||||
dm.name,
|
||||
dm.messages,
|
||||
dm.first_message,
|
||||
dm.last_message,
|
||||
coalesce((
|
||||
select ac.author_id
|
||||
from author_counts ac, self
|
||||
where ac.channel_id = dm.channel_id and ac.author_id <> self.author_id
|
||||
order by ac.messages desc
|
||||
limit 1
|
||||
), '') as counterpart_id
|
||||
from dm
|
||||
order by dm.messages desc, lower(dm.name);
|
||||
`
|
||||
70
internal/discrawl/discrawl_test.go
Normal file
70
internal/discrawl/discrawl_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package discrawl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListDMContactsViaSQLiteAdapter(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
script := `#!/bin/sh
|
||||
case "$*" in
|
||||
*'count(m.id) > 4'*) ;;
|
||||
*) echo "missing threshold" >&2; exit 2 ;;
|
||||
esac
|
||||
cat <<'JSON'
|
||||
[{"channel_id":"c1","name":"Ada DM","messages":5,"first_message":"2026-01-01T00:00:00Z","last_message":"2026-01-02T00:00:00Z","counterpart_id":"u1"}]
|
||||
JSON
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := (Adapter{DBPath: "~/discrawl.db", Binary: bin}).ListDMContacts(t.Context(), 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Name != "Ada DM" || contacts[0].Accounts["discord"][1] != "user:u1" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDMContactsErrors(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), 0); err == nil || !strings.Contains(err.Error(), "locked") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
bad := filepath.Join(t.TempDir(), "sqlite3-bad")
|
||||
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho not-json\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (Adapter{Binary: bad}).ListDMContacts(t.Context(), 4); err == nil {
|
||||
t.Fatal("expected json error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDMContactsEmptyAndFilters(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "sqlite3")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[{\"channel_id\":\"\",\"name\":\"Skip\",\"messages\":8},{\"channel_id\":\"c2\",\"name\":\" \",\"messages\":8}]'\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := (Adapter{Binary: bin}).ListDMContacts(t.Context(), -1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 0 {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
empty := filepath.Join(t.TempDir(), "sqlite3-empty")
|
||||
if err := os.WriteFile(empty, []byte("#!/bin/sh\nprintf ' '\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err = (Adapter{Binary: empty}).ListDMContacts(t.Context(), 4)
|
||||
if err != nil || len(contacts) != 0 {
|
||||
t.Fatalf("contacts=%#v err=%v", contacts, err)
|
||||
}
|
||||
}
|
||||
154
internal/google/gog.go
Normal file
154
internal/google/gog.go
Normal file
@ -0,0 +1,154 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
type GogAdapter struct {
|
||||
Binary string
|
||||
}
|
||||
|
||||
func (g GogAdapter) ListContacts(ctx context.Context, account string) ([]model.SourceContact, error) {
|
||||
binary := g.Binary
|
||||
if binary == "" {
|
||||
binary = "gog"
|
||||
}
|
||||
var out []model.SourceContact
|
||||
page := ""
|
||||
for {
|
||||
args := []string{"--no-input", "contacts", "list", "--json", "--max", "1000"}
|
||||
if page != "" {
|
||||
args = append(args, "--page", page)
|
||||
}
|
||||
if strings.TrimSpace(account) != "" {
|
||||
args = append([]string{"--account", account}, args...)
|
||||
}
|
||||
// #nosec G204 -- the adapter intentionally shells to a configured gog binary without using a shell.
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
raw, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return nil, fmt.Errorf("gog contacts list: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
contacts, nextPage, err := parseGogContactsPage(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, contacts...)
|
||||
if nextPage == "" {
|
||||
return out, nil
|
||||
}
|
||||
page = nextPage
|
||||
}
|
||||
}
|
||||
|
||||
type gogEnvelope struct {
|
||||
Contacts []gogPerson `json:"contacts"`
|
||||
Results []gogPerson `json:"results"`
|
||||
People []gogPerson `json:"people"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
type gogPerson struct {
|
||||
ResourceName string `json:"resourceName"`
|
||||
Resource string `json:"resource"`
|
||||
ETag string `json:"etag"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Emails []string `json:"emails"`
|
||||
Phones []string `json:"phones"`
|
||||
EmailAddresses []struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"emailAddresses"`
|
||||
PhoneNumbers []struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"phoneNumbers"`
|
||||
Names []struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
GivenName string `json:"givenName"`
|
||||
FamilyName string `json:"familyName"`
|
||||
} `json:"names"`
|
||||
}
|
||||
|
||||
func parseGogContacts(data []byte) ([]model.SourceContact, error) {
|
||||
contacts, _, err := parseGogContactsPage(data)
|
||||
return contacts, err
|
||||
}
|
||||
|
||||
func parseGogContactsPage(data []byte) ([]model.SourceContact, string, error) {
|
||||
var env gogEnvelope
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
people := make([]gogPerson, 0, len(env.Contacts)+len(env.Results)+len(env.People))
|
||||
people = append(people, env.Contacts...)
|
||||
people = append(people, env.Results...)
|
||||
people = append(people, env.People...)
|
||||
if len(people) > 0 {
|
||||
return convertPeople(people), env.NextPageToken, nil
|
||||
}
|
||||
}
|
||||
var people []gogPerson
|
||||
if err := json.Unmarshal(data, &people); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return convertPeople(people), "", nil
|
||||
}
|
||||
|
||||
func convertPeople(people []gogPerson) []model.SourceContact {
|
||||
out := make([]model.SourceContact, 0, len(people))
|
||||
for _, p := range people {
|
||||
name := p.Name
|
||||
if name == "" && len(p.Names) > 0 {
|
||||
name = p.Names[0].DisplayName
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(p.Names[0].GivenName + " " + p.Names[0].FamilyName)
|
||||
}
|
||||
}
|
||||
c := model.SourceContact{Source: "google", ExternalID: firstNonEmpty(p.ResourceName, p.Resource), Name: name, ETag: p.ETag}
|
||||
for i, email := range append(p.Emails, p.Email) {
|
||||
if strings.TrimSpace(email) != "" {
|
||||
c.Emails = append(c.Emails, model.ContactValue{Value: email, Source: "google", Primary: i == 0})
|
||||
}
|
||||
}
|
||||
for _, email := range p.EmailAddresses {
|
||||
if strings.TrimSpace(email.Value) != "" {
|
||||
c.Emails = append(c.Emails, model.ContactValue{Value: email.Value, Label: email.Type, Source: "google"})
|
||||
}
|
||||
}
|
||||
for i, phone := range append(p.Phones, p.Phone) {
|
||||
if strings.TrimSpace(phone) != "" {
|
||||
c.Phones = append(c.Phones, model.ContactValue{Value: phone, Source: "google", Primary: i == 0})
|
||||
}
|
||||
}
|
||||
for _, phone := range p.PhoneNumbers {
|
||||
if strings.TrimSpace(phone.Value) != "" {
|
||||
c.Phones = append(c.Phones, model.ContactValue{Value: phone.Value, Label: phone.Type, Source: "google"})
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.Name) != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
87
internal/google/gog_test.go
Normal file
87
internal/google/gog_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGogContactsEnvelopeAndArray(t *testing.T) {
|
||||
inputs := [][]byte{
|
||||
[]byte(`{"contacts":[{"resourceName":"people/c1","etag":"e1","name":"Ada","email":"ada@example.com","phone":"+1 555 0100"}]}`),
|
||||
[]byte(`[{"resource":"people/c1","names":[{"displayName":"Ada"}],"emailAddresses":[{"value":"ada@example.com","type":"home"}],"phoneNumbers":[{"value":"+1","type":"mobile"}]}]`),
|
||||
}
|
||||
for _, input := range inputs {
|
||||
contacts, err := parseGogContacts(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].Source != "google" || contacts[0].Name != "Ada" {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
if contacts[0].ExternalID != "people/c1" || len(contacts[0].Emails) == 0 {
|
||||
t.Fatalf("bad contact = %#v", contacts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGogAdapterListContactsUsesNoInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "gog")
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".bat"
|
||||
}
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"" + filepath.Join(dir, "args") + "\"\ncase \"$*\" in *next*) printf '%s\\n' '{\"contacts\":[{\"resourceName\":\"people/c2\",\"name\":\"Grace\"}]}' ;; *) printf '%s\\n' '{\"contacts\":[{\"resourceName\":\"people/c1\",\"name\":\"Ada\"}],\"nextPageToken\":\"next\"}' ;; esac\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, err := (GogAdapter{Binary: bin}).ListContacts(t.Context(), "ada@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 2 {
|
||||
t.Fatalf("contacts = %#v", contacts)
|
||||
}
|
||||
args, err := os.ReadFile(filepath.Join(dir, "args"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(args), "--no-input") || !strings.Contains(string(args), "--account") {
|
||||
t.Fatalf("args = %s", args)
|
||||
}
|
||||
if !strings.Contains(string(args), "--page") {
|
||||
t.Fatalf("missing page args = %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGogAdapterListContactsCommandFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "gog")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho nope >&2\nexit 7\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := (GogAdapter{Binary: bin}).ListContacts(t.Context(), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "nope") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, err := (GogAdapter{Binary: filepath.Join(dir, "missing")}).ListContacts(t.Context(), ""); err == nil {
|
||||
t.Fatal("expected missing binary error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGogContactsRejectsInvalidJSON(t *testing.T) {
|
||||
if _, err := parseGogContacts([]byte(`{`)); err == nil || !strings.Contains(err.Error(), "unexpected") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var p gogPerson
|
||||
if err := json.Unmarshal([]byte(`{"names":[{"givenName":"Ada","familyName":"Lovelace"}]}`), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := convertPeople([]gogPerson{p})
|
||||
if len(got) != 1 || got[0].Name != "Ada Lovelace" {
|
||||
t.Fatalf("got = %#v", got)
|
||||
}
|
||||
}
|
||||
289
internal/index/import.go
Normal file
289
internal/index/import.go
Normal file
@ -0,0 +1,289 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/markdown"
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func (s Store) ImportContacts(source string, contacts []model.SourceContact, dryRun bool, now time.Time) ([]model.ImportChange, error) {
|
||||
people, err := s.People()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []model.ImportChange
|
||||
for _, contact := range contacts {
|
||||
contact.Source = source
|
||||
if strings.TrimSpace(contact.Name) == "" {
|
||||
continue
|
||||
}
|
||||
idx := matchContact(people, contact)
|
||||
if idx < 0 {
|
||||
p := markdown.NewPerson(contact.Name, now)
|
||||
p.Tags = cleanList(contact.Tags)
|
||||
p.Emails = sourceValues(contact.Emails, source)
|
||||
p.Phones = sourceValues(contact.Phones, source)
|
||||
p.Accounts = cleanAccounts(contact.Accounts)
|
||||
setExternal(&p, source, contact, now)
|
||||
change := model.ImportChange{Action: "create", PersonID: p.ID, Name: p.Name, Source: contact}
|
||||
if !dryRun {
|
||||
created, err := s.createImportedPerson(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change.PersonID = created.ID
|
||||
change.Path = created.Path
|
||||
people = append(people, created)
|
||||
}
|
||||
changes = append(changes, change)
|
||||
continue
|
||||
}
|
||||
p := people[idx]
|
||||
beforeEmails := len(p.Emails)
|
||||
beforePhones := len(p.Phones)
|
||||
beforeTags := append([]string(nil), p.Tags...)
|
||||
beforeAccounts := cloneAccounts(p.Accounts)
|
||||
beforeApple := p.Apple
|
||||
beforeGoogle := p.Google
|
||||
p.Tags = appendMissingStrings(p.Tags, contact.Tags)
|
||||
p.Emails = appendMissingValues(p.Emails, contact.Emails, source)
|
||||
p.Phones = appendMissingValues(p.Phones, contact.Phones, source)
|
||||
p.Accounts = mergeAccounts(p.Accounts, contact.Accounts)
|
||||
setExternal(&p, source, contact, now)
|
||||
externalChanged := p.Apple != beforeApple || p.Google != beforeGoogle
|
||||
tagsChanged := strings.Join(beforeTags, "\x00") != strings.Join(p.Tags, "\x00")
|
||||
accountsChanged := !accountsEqual(beforeAccounts, p.Accounts)
|
||||
if len(p.Emails) == beforeEmails && len(p.Phones) == beforePhones && !tagsChanged && !accountsChanged && !externalChanged {
|
||||
continue
|
||||
}
|
||||
change := model.ImportChange{Action: "update", PersonID: p.ID, Name: p.Name, Source: contact, Path: p.Path}
|
||||
if !dryRun {
|
||||
p.UpdatedAt = now.UTC()
|
||||
if err := markdown.WritePerson(p.Path, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people[idx] = p
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
if !dryRun {
|
||||
return changes, s.Rebuild()
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func matchContact(people []model.Person, contact model.SourceContact) int {
|
||||
for i, p := range people {
|
||||
if accountsOverlap(p.Accounts, contact.Accounts) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for i, p := range people {
|
||||
switch contact.Source {
|
||||
case "apple":
|
||||
if contact.ExternalID != "" && p.Apple.ID == contact.ExternalID {
|
||||
return i
|
||||
}
|
||||
case "google":
|
||||
if contact.ExternalID != "" && p.Google.Resource == contact.ExternalID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, p := range people {
|
||||
for _, email := range contact.Emails {
|
||||
if model.NormalizeEmail(email.Value) != "" && personHasEmail(p, model.NormalizeEmail(email.Value)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, p := range people {
|
||||
for _, phone := range contact.Phones {
|
||||
if model.NormalizePhone(phone.Value) != "" && personHasPhone(p, model.NormalizePhone(phone.Value)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, p := range people {
|
||||
if model.NormalizeName(p.Name) != "" && model.NormalizeName(p.Name) == model.NormalizeName(contact.Name) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func sourceValues(values []model.ContactValue, source string) []model.ContactValue {
|
||||
out := make([]model.ContactValue, 0, len(values))
|
||||
for i, value := range values {
|
||||
value.Source = source
|
||||
if value.Label == "" {
|
||||
value.Label = "other"
|
||||
}
|
||||
if i == 0 {
|
||||
value.Primary = true
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanAccounts(accounts map[string][]string) map[string][]string {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for service, values := range accounts {
|
||||
service = strings.TrimSpace(strings.ToLower(service))
|
||||
if service == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := cleanList(values)
|
||||
if len(cleaned) > 0 {
|
||||
out[service] = cleaned
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAccounts(accounts map[string][]string) map[string][]string {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string][]string, len(accounts))
|
||||
for service, values := range accounts {
|
||||
out[service] = append([]string(nil), values...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeAccounts(existing map[string][]string, incoming map[string][]string) map[string][]string {
|
||||
if len(incoming) == 0 {
|
||||
return existing
|
||||
}
|
||||
if existing == nil {
|
||||
existing = map[string][]string{}
|
||||
}
|
||||
for service, values := range cleanAccounts(incoming) {
|
||||
existing[service] = appendMissingStrings(existing[service], values)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func appendMissingStrings(existing []string, incoming []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range existing {
|
||||
seen[strings.ToLower(strings.TrimSpace(value))] = true
|
||||
}
|
||||
for _, value := range incoming {
|
||||
value = strings.TrimSpace(value)
|
||||
key := strings.ToLower(value)
|
||||
if value == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, value)
|
||||
seen[key] = true
|
||||
}
|
||||
sort.Strings(existing)
|
||||
return existing
|
||||
}
|
||||
|
||||
func accountsOverlap(existing map[string][]string, incoming map[string][]string) bool {
|
||||
for service, values := range cleanAccounts(incoming) {
|
||||
current := existing[service]
|
||||
for _, value := range values {
|
||||
for _, cur := range current {
|
||||
if strings.EqualFold(strings.TrimSpace(cur), strings.TrimSpace(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func accountsEqual(a, b map[string][]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for service, av := range a {
|
||||
bv := b[service]
|
||||
if len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for i := range av {
|
||||
if av[i] != bv[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func appendMissingValues(existing []model.ContactValue, incoming []model.ContactValue, source string) []model.ContactValue {
|
||||
for _, value := range incoming {
|
||||
key := model.NormalizeEmail(value.Value)
|
||||
if key == "" {
|
||||
key = model.NormalizePhone(value.Value)
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, cur := range existing {
|
||||
curKey := model.NormalizeEmail(cur.Value)
|
||||
if curKey == "" {
|
||||
curKey = model.NormalizePhone(cur.Value)
|
||||
}
|
||||
if curKey == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
value.Source = source
|
||||
if value.Label == "" {
|
||||
value.Label = "other"
|
||||
}
|
||||
existing = append(existing, value)
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func setExternal(p *model.Person, source string, contact model.SourceContact, now time.Time) {
|
||||
switch source {
|
||||
case "apple":
|
||||
if contact.ExternalID == "" {
|
||||
return
|
||||
}
|
||||
p.Apple.ID = contact.ExternalID
|
||||
p.Apple.LastSeenAt = now.UTC()
|
||||
case "google":
|
||||
if contact.ExternalID == "" && contact.ETag == "" {
|
||||
return
|
||||
}
|
||||
p.Google.Resource = contact.ExternalID
|
||||
p.Google.ETag = contact.ETag
|
||||
p.Google.LastSeenAt = now.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store) createImportedPerson(p model.Person) (model.Person, error) {
|
||||
dir, err := s.uniquePersonDir(model.Slug(p.Name))
|
||||
if err != nil {
|
||||
return model.Person{}, err
|
||||
}
|
||||
p.Path = filepath.Join(dir, "person.md")
|
||||
p.Body = "# " + p.Name + "\n"
|
||||
if err := markdown.WritePerson(p.Path, p); err != nil {
|
||||
return model.Person{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
345
internal/index/store.go
Normal file
345
internal/index/store.go
Normal file
@ -0,0 +1,345 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/markdown"
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
"github.com/openclaw/clawdex/internal/repo"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
Repo repo.Repo
|
||||
}
|
||||
|
||||
func New(r repo.Repo) Store {
|
||||
return Store{Repo: r}
|
||||
}
|
||||
|
||||
func (s Store) AddPerson(name string, emails, phones, tags []string, now time.Time) (model.Person, error) {
|
||||
p := markdown.NewPerson(name, now)
|
||||
p.Tags = cleanList(tags)
|
||||
for i, email := range cleanList(emails) {
|
||||
p.Emails = append(p.Emails, model.ContactValue{Value: email, Label: "other", Source: "manual", Primary: i == 0})
|
||||
}
|
||||
for i, phone := range cleanList(phones) {
|
||||
p.Phones = append(p.Phones, model.ContactValue{Value: phone, Label: "other", Source: "manual", Primary: i == 0})
|
||||
}
|
||||
dir, err := s.uniquePersonDir(model.Slug(name))
|
||||
if err != nil {
|
||||
return model.Person{}, err
|
||||
}
|
||||
path := filepath.Join(dir, "person.md")
|
||||
p.Path = path
|
||||
p.Body = "# " + p.Name + "\n"
|
||||
if err := markdown.WritePerson(path, p); err != nil {
|
||||
return model.Person{}, err
|
||||
}
|
||||
return p, s.Rebuild()
|
||||
}
|
||||
|
||||
func (s Store) People() ([]model.Person, error) {
|
||||
if err := s.Repo.Require(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(s.Repo.PeopleDir())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
people := make([]model.Person, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.Repo.PeopleDir(), entry.Name(), "person.md")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
p, report, err := markdown.ReadPerson(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if report.Needed && s.Repo.Config.Repair.AutoRepair {
|
||||
if err := markdown.RepairPerson(path, s.Repo.RepairDir(), p, report, s.Repo.Config.Repair.BackupBeforeRepair); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
people = append(people, p)
|
||||
}
|
||||
sort.Slice(people, func(i, j int) bool {
|
||||
return strings.ToLower(people[i].Name) < strings.ToLower(people[j].Name)
|
||||
})
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func (s Store) FindPerson(query string) (model.Person, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return model.Person{}, errors.New("person query is required")
|
||||
}
|
||||
people, err := s.People()
|
||||
if err != nil {
|
||||
return model.Person{}, err
|
||||
}
|
||||
var matches []model.Person
|
||||
nq := model.NormalizeName(query)
|
||||
eq := model.NormalizeEmail(query)
|
||||
pq := model.NormalizePhone(query)
|
||||
for _, p := range people {
|
||||
switch {
|
||||
case p.ID == query:
|
||||
return p, nil
|
||||
case model.Slug(p.Name) == model.Slug(query):
|
||||
matches = append(matches, p)
|
||||
case strings.Contains(model.NormalizeName(p.Name), nq):
|
||||
matches = append(matches, p)
|
||||
case personHasEmail(p, eq):
|
||||
matches = append(matches, p)
|
||||
case pq != "" && personHasPhone(p, pq):
|
||||
matches = append(matches, p)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return model.Person{}, fmt.Errorf("no person matched %q", query)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
names := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
names = append(names, match.Name+" ("+match.ID+")")
|
||||
}
|
||||
return model.Person{}, fmt.Errorf("ambiguous person %q: %s", query, strings.Join(names, ", "))
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (s Store) AddNote(personQuery string, note model.Note) (model.Note, error) {
|
||||
p, err := s.FindPerson(personQuery)
|
||||
if err != nil {
|
||||
return model.Note{}, err
|
||||
}
|
||||
note.PersonID = p.ID
|
||||
dir := filepath.Join(filepath.Dir(p.Path), "notes")
|
||||
path := filepath.Join(dir, markdown.NoteFileName(note))
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
}
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, ext)
|
||||
path = fmt.Sprintf("%s-%d%s", base, i, ext)
|
||||
}
|
||||
note.Path = path
|
||||
if err := markdown.WriteNote(path, note); err != nil {
|
||||
return model.Note{}, err
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
func (s Store) Notes(personQuery string) ([]model.Note, error) {
|
||||
p, err := s.FindPerson(personQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.notesForPerson(p)
|
||||
}
|
||||
|
||||
func (s Store) notesForPerson(p model.Person) ([]model.Note, error) {
|
||||
dir := filepath.Join(filepath.Dir(p.Path), "notes")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes := make([]model.Note, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
|
||||
continue
|
||||
}
|
||||
n, _, err := markdown.ReadNote(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.PersonID == "" {
|
||||
n.PersonID = p.ID
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
sort.Slice(notes, func(i, j int) bool {
|
||||
return notes[i].OccurredAt.Before(notes[j].OccurredAt)
|
||||
})
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (s Store) Search(query string) ([]model.SearchHit, error) {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return nil, errors.New("search query is required")
|
||||
}
|
||||
people, err := s.People()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hits []model.SearchHit
|
||||
for _, p := range people {
|
||||
text := personSearchText(p)
|
||||
if score := scoreText(text, query); score > 0 {
|
||||
hits = append(hits, model.SearchHit{Kind: "person", ID: p.ID, Name: p.Name, Path: p.Path, Score: score, Snippet: p.Name})
|
||||
}
|
||||
notes, err := s.notesForPerson(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range notes {
|
||||
text := strings.ToLower(strings.Join(append([]string{n.Kind, n.Source, n.Body}, n.Topics...), " "))
|
||||
if score := scoreText(text, query); score > 0 {
|
||||
hits = append(hits, model.SearchHit{Kind: "note", ID: n.ID, PersonID: p.ID, Name: p.Name, Path: n.Path, Score: score, Snippet: snippet(n.Body, query), Timestamp: n.OccurredAt})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
if hits[i].Score == hits[j].Score {
|
||||
return hits[i].Path < hits[j].Path
|
||||
}
|
||||
return hits[i].Score > hits[j].Score
|
||||
})
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func (s Store) Rebuild() error {
|
||||
people, err := s.People()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(s.Repo.IndexDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
emails := map[string]string{}
|
||||
phones := map[string]string{}
|
||||
handles := map[string]string{}
|
||||
for _, p := range people {
|
||||
for _, email := range p.Emails {
|
||||
if v := model.NormalizeEmail(email.Value); v != "" {
|
||||
emails[v] = p.ID
|
||||
}
|
||||
}
|
||||
for _, phone := range p.Phones {
|
||||
if v := model.NormalizePhone(phone.Value); v != "" {
|
||||
phones[v] = p.ID
|
||||
}
|
||||
}
|
||||
for service, values := range p.Accounts {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
handles[strings.ToLower(service+":"+value)] = p.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, value := range map[string]map[string]string{
|
||||
"emails.json": emails,
|
||||
"phones.json": phones,
|
||||
"handles.json": handles,
|
||||
} {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.Repo.IndexDir(), name), append(data, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) uniquePersonDir(slug string) (string, error) {
|
||||
if slug == "" {
|
||||
slug = "person"
|
||||
}
|
||||
for i := 0; ; i++ {
|
||||
candidate := slug
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", slug, i+1)
|
||||
}
|
||||
path := filepath.Join(s.Repo.PeopleDir(), candidate)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanList(values []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func personHasEmail(p model.Person, email string) bool {
|
||||
for _, v := range p.Emails {
|
||||
if model.NormalizeEmail(v.Value) == email {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personHasPhone(p model.Person, phone string) bool {
|
||||
for _, v := range p.Phones {
|
||||
if model.NormalizePhone(v.Value) == phone {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personSearchText(p model.Person) string {
|
||||
parts := []string{p.ID, p.Name, p.SortName, p.Body}
|
||||
parts = append(parts, p.Tags...)
|
||||
for _, email := range p.Emails {
|
||||
parts = append(parts, email.Value)
|
||||
}
|
||||
for _, phone := range p.Phones {
|
||||
parts = append(parts, phone.Value)
|
||||
}
|
||||
for service, values := range p.Accounts {
|
||||
parts = append(parts, service)
|
||||
parts = append(parts, values...)
|
||||
}
|
||||
return strings.ToLower(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func scoreText(text, query string) int {
|
||||
if text == query {
|
||||
return 100
|
||||
}
|
||||
return strings.Count(text, query)
|
||||
}
|
||||
|
||||
func snippet(body, query string) string {
|
||||
lower := strings.ToLower(body)
|
||||
idx := strings.Index(lower, query)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
start := idx - 40
|
||||
start = max(start, 0)
|
||||
end := idx + len(query) + 80
|
||||
end = min(end, len(body))
|
||||
return strings.TrimSpace(body[start:end])
|
||||
}
|
||||
364
internal/index/store_test.go
Normal file
364
internal/index/store_test.go
Normal file
@ -0,0 +1,364 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/markdown"
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
"github.com/openclaw/clawdex/internal/repo"
|
||||
)
|
||||
|
||||
func TestAddNoteAndSearch(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
p, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := markdown.NewNote(p.ID, "dm", "manual", "Analytical engine follow-up", time.Time{}, time.Now(), []string{"math"})
|
||||
if _, err := s.AddNote("ada@example.com", n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := s.Search("engine")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Kind != "note" {
|
||||
t.Fatalf("hits = %#v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPersonVariantsAndErrors(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
p, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, []string{"+1 555 0100"}, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.AddPerson("Ada Lovelace", []string{"ada2@example.com"}, nil, nil, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, query := range []string{p.ID, "ada@example.com", "15550100"} {
|
||||
got, err := s.FindPerson(query)
|
||||
if err != nil || got.ID != p.ID {
|
||||
t.Fatalf("query %q got=%#v err=%v", query, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.FindPerson("ada"); err == nil {
|
||||
t.Fatal("expected ambiguous name")
|
||||
}
|
||||
if _, err := s.FindPerson("missing"); err == nil {
|
||||
t.Fatal("expected missing")
|
||||
}
|
||||
if _, err := s.FindPerson(""); err == nil {
|
||||
t.Fatal("expected empty query error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesMissingDirAndDuplicateNames(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
if _, err := s.AddPerson("Ada Lovelace", nil, nil, nil, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notes, err := s.Notes("Ada Lovelace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %#v", notes)
|
||||
}
|
||||
now := time.Date(2026, 5, 8, 9, 0, 0, 0, time.UTC)
|
||||
n := markdown.NewNote("", "dm", "manual", "first", now, now, nil)
|
||||
if _, err := s.AddNote("Ada Lovelace", n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n.Body = "second"
|
||||
if _, err := s.AddNote("Ada Lovelace", n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notes, err = s.Notes("Ada Lovelace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notes) != 2 || notes[0].Body == notes[1].Body {
|
||||
t.Fatalf("notes = %#v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportMatchesEmail(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
if _, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes, err := s.ImportContacts("google", []model.SourceContact{{
|
||||
Source: "google",
|
||||
Name: "Ada Lovelace",
|
||||
Emails: []model.ContactValue{{Value: "ADA@example.com"}},
|
||||
Phones: []model.ContactValue{{Value: "+1 555 0100"}},
|
||||
}}, false, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "update" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
p, err := s.FindPerson("ada@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Phones) != 1 {
|
||||
t.Fatalf("phones = %#v", p.Phones)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWritesExternalOnlyChange(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
if _, err := s.AddPerson("Ada Lovelace", []string{"ada@example.com"}, nil, nil, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes, err := s.ImportContacts("google", []model.SourceContact{{
|
||||
ExternalID: "people/c1",
|
||||
Name: "Ada Lovelace",
|
||||
Emails: []model.ContactValue{{Value: "ada@example.com"}},
|
||||
}}, false, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "update" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
p, err := s.FindPerson("ada@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Google.Resource != "people/c1" {
|
||||
t.Fatalf("google = %#v", p.Google)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCreateDryRunAndExternalID(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
now := time.Now()
|
||||
changes, err := s.ImportContacts("apple", []model.SourceContact{{
|
||||
ExternalID: "a1",
|
||||
Name: "Ada Apple",
|
||||
Emails: []model.ContactValue{{Value: "apple@example.com"}},
|
||||
}}, true, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "create" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
if _, err := s.FindPerson("apple@example.com"); err == nil {
|
||||
t.Fatal("dry-run created person")
|
||||
}
|
||||
if _, err := s.ImportContacts("apple", []model.SourceContact{{
|
||||
ExternalID: "a1",
|
||||
Name: "Ada Apple",
|
||||
Emails: []model.ContactValue{{Value: "apple@example.com"}},
|
||||
}}, false, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := s.FindPerson("apple@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Apple.ID != "a1" {
|
||||
t.Fatalf("apple ref = %#v", p.Apple)
|
||||
}
|
||||
changes, err = s.ImportContacts("apple", []model.SourceContact{{
|
||||
ExternalID: "a1",
|
||||
Name: "Ada Renamed",
|
||||
Phones: []model.ContactValue{{Value: "+1 555 0101"}},
|
||||
}}, false, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "update" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTagsAccountsAndExactNameMatch(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
now := time.Now()
|
||||
changes, err := s.ImportContacts("discord", []model.SourceContact{{
|
||||
ExternalID: "channel1",
|
||||
Name: "Discord Person",
|
||||
Tags: []string{"discord", "dm"},
|
||||
Accounts: map[string][]string{"discord": {"channel:channel1", "user:user1"}},
|
||||
}}, false, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "create" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
p, err := s.FindPerson("Discord Person")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Tags) != 2 || len(p.Accounts["discord"]) != 2 {
|
||||
t.Fatalf("person = %#v", p)
|
||||
}
|
||||
changes, err = s.ImportContacts("discord", []model.SourceContact{{
|
||||
Name: "Discord Person",
|
||||
Tags: []string{"discord", "dm", "friend"},
|
||||
Accounts: map[string][]string{"discord": {"channel:channel1"}},
|
||||
}}, false, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "update" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
p, err = s.FindPerson("Discord Person")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stringIn(p.Tags, "friend") {
|
||||
t.Fatalf("tags = %#v", p.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchPersonAndEmptyQuery(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
if _, err := s.AddPerson("Ada Lovelace", nil, nil, []string{"math"}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := s.Search("math")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Kind != "person" {
|
||||
t.Fatalf("hits = %#v", hits)
|
||||
}
|
||||
if _, err := s.Search(""); err == nil {
|
||||
t.Fatal("expected empty search error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAccountsAndBadNoteError(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
p, err := s.AddPerson("Handle Person", nil, nil, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Accounts = map[string][]string{"github": {"handle-person"}}
|
||||
if err := markdown.WritePerson(p.Path, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := s.Search("handle-person")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Kind != "person" {
|
||||
t.Fatalf("hits = %#v", hits)
|
||||
}
|
||||
notesDir := filepath.Join(filepath.Dir(p.Path), "notes")
|
||||
if err := os.MkdirAll(notesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(notesDir, "missing"), filepath.Join(notesDir, "bad.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Search("handle-person"); err == nil {
|
||||
t.Fatal("expected bad note read error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmallHelpers(t *testing.T) {
|
||||
if got := cleanList([]string{"a", "a", "", " b "}); len(got) != 2 || got[1] != "b" {
|
||||
t.Fatalf("clean = %#v", got)
|
||||
}
|
||||
if scoreText("abc", "abc") != 100 || scoreText("abc xyz", "abc") != 1 || scoreText("abc", "z") != 0 {
|
||||
t.Fatal("bad scores")
|
||||
}
|
||||
if got := snippet("short body", "missing"); got != "" {
|
||||
t.Fatalf("snippet = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeopleAutoRepairRebuildAccountsAndImportNoop(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
p, err := s.AddPerson("Ada Accounts", []string{"acct@example.com"}, nil, nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Accounts = map[string][]string{"github": {"ada"}}
|
||||
if err := markdown.WritePerson(p.Path, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Rebuild(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
people, err := s.People()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(people) != 1 {
|
||||
t.Fatalf("people = %#v", people)
|
||||
}
|
||||
changes, err := s.ImportContacts("google", []model.SourceContact{{Name: ""}, {Name: "Ada Accounts", Emails: []model.ContactValue{{Value: "acct@example.com"}}}}, false, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportMatchesPhoneAndGoogleExternal(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
s := New(r)
|
||||
if _, err := s.ImportContacts("google", []model.SourceContact{{ExternalID: "people/c1", Name: "Ada Google", Phones: []model.ContactValue{{Value: "+1 555 0100"}}}}, false, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes, err := s.ImportContacts("google", []model.SourceContact{{ExternalID: "people/c1", Name: "Ada Google", Emails: []model.ContactValue{{Value: "g@example.com"}}}}, false, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Action != "update" {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
p, err := s.FindPerson("+1 555 0100")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Google.Resource != "people/c1" {
|
||||
t.Fatalf("google = %#v", p.Google)
|
||||
}
|
||||
}
|
||||
|
||||
func testRepo(t *testing.T) repo.Repo {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := repo.DefaultConfig()
|
||||
cfg.RepoPath = dir
|
||||
cfg.Git.Remote = ""
|
||||
r := repo.Open(dir, cfg)
|
||||
if err := r.Init(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := filepath.Base(r.PeopleDir()); got != "people" {
|
||||
t.Fatalf("bad people dir: %s", r.PeopleDir())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func stringIn(values []string, want string) bool {
|
||||
return slices.Contains(values, want)
|
||||
}
|
||||
451
internal/markdown/markdown.go
Normal file
451
internal/markdown/markdown.go
Normal file
@ -0,0 +1,451 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Path string `json:"path"`
|
||||
Needed bool `json:"needed"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
RecoveredMetadata string `json:"recovered_metadata,omitempty"`
|
||||
}
|
||||
|
||||
func NewPerson(name string, now time.Time) model.Person {
|
||||
return model.Person{
|
||||
ID: "person_" + uuid.NewString(),
|
||||
Name: strings.TrimSpace(name),
|
||||
CreatedAt: now.UTC(),
|
||||
UpdatedAt: now.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewNote(personID, kind, source, body string, occurredAt, now time.Time, topics []string) model.Note {
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = now
|
||||
}
|
||||
return model.Note{
|
||||
ID: "note_" + uuid.NewString(),
|
||||
PersonID: personID,
|
||||
OccurredAt: occurredAt.UTC(),
|
||||
CapturedAt: now.UTC(),
|
||||
Kind: strings.TrimSpace(kind),
|
||||
Source: strings.TrimSpace(source),
|
||||
Confidence: "high",
|
||||
Privacy: "normal",
|
||||
Topics: topics,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func ReadPerson(path string) (model.Person, RepairReport, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return model.Person{}, RepairReport{}, err
|
||||
}
|
||||
front, body, ok := splitFrontmatter(data)
|
||||
report := RepairReport{Path: path}
|
||||
var p model.Person
|
||||
if ok {
|
||||
if err := yaml.Unmarshal([]byte(front), &p); err != nil {
|
||||
report.Needed = true
|
||||
report.Problems = append(report.Problems, "invalid YAML frontmatter: "+err.Error())
|
||||
report.RecoveredMetadata = front
|
||||
p = salvagePerson(front)
|
||||
}
|
||||
} else {
|
||||
report.Needed = true
|
||||
report.Problems = append(report.Problems, "missing YAML frontmatter")
|
||||
body = string(data)
|
||||
}
|
||||
p.Body = strings.TrimLeft(body, "\n")
|
||||
p.Path = path
|
||||
inferPerson(&p, path)
|
||||
return p, report, nil
|
||||
}
|
||||
|
||||
func WritePerson(path string, p model.Person) error {
|
||||
inferPerson(&p, path)
|
||||
p.UpdatedAt = p.UpdatedAt.UTC()
|
||||
front, err := yaml.Marshal(personFrontmatter(p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimLeft(p.Body, "\n")
|
||||
if body == "" {
|
||||
body = "# " + p.Name + "\n"
|
||||
}
|
||||
return atomicWrite(path, appendFrontmatter(front, body), 0o600)
|
||||
}
|
||||
|
||||
func RepairPerson(path, repairRoot string, p model.Person, report RepairReport, backup bool) error {
|
||||
if !report.Needed {
|
||||
return nil
|
||||
}
|
||||
if backup {
|
||||
if err := backupOriginal(path, repairRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if report.RecoveredMetadata != "" && !strings.Contains(p.Body, "## Recovered metadata") {
|
||||
p.Body = strings.TrimRight(p.Body, "\n") + "\n\n## Recovered metadata\n\n```yaml\n" + strings.TrimSpace(report.RecoveredMetadata) + "\n```\n"
|
||||
}
|
||||
return WritePerson(path, p)
|
||||
}
|
||||
|
||||
func ReadNote(path string) (model.Note, RepairReport, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return model.Note{}, RepairReport{}, err
|
||||
}
|
||||
front, body, ok := splitFrontmatter(data)
|
||||
report := RepairReport{Path: path}
|
||||
var n model.Note
|
||||
if ok {
|
||||
if err := yaml.Unmarshal([]byte(front), &n); err != nil {
|
||||
report.Needed = true
|
||||
report.Problems = append(report.Problems, "invalid YAML frontmatter: "+err.Error())
|
||||
report.RecoveredMetadata = front
|
||||
n = salvageNote(front)
|
||||
}
|
||||
} else {
|
||||
report.Needed = true
|
||||
report.Problems = append(report.Problems, "missing YAML frontmatter")
|
||||
body = string(data)
|
||||
}
|
||||
n.Body = strings.TrimLeft(body, "\n")
|
||||
n.Path = path
|
||||
inferNote(&n, path)
|
||||
return n, report, nil
|
||||
}
|
||||
|
||||
func WriteNote(path string, n model.Note) error {
|
||||
inferNote(&n, path)
|
||||
front, err := yaml.Marshal(noteFrontmatter(n))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(path, appendFrontmatter(front, strings.TrimLeft(n.Body, "\n")), 0o600)
|
||||
}
|
||||
|
||||
func splitFrontmatter(data []byte) (string, string, bool) {
|
||||
text := string(data)
|
||||
if !strings.HasPrefix(text, "---\n") && !strings.HasPrefix(text, "---\r\n") {
|
||||
return "", text, false
|
||||
}
|
||||
normalized := strings.ReplaceAll(text, "\r\n", "\n")
|
||||
rest := normalized[4:]
|
||||
front, body, ok := strings.Cut(rest, "\n---\n")
|
||||
if !ok {
|
||||
if front, ok := strings.CutSuffix(rest, "\n---"); ok {
|
||||
return front, "", true
|
||||
}
|
||||
return "", text, false
|
||||
}
|
||||
return front, body, true
|
||||
}
|
||||
|
||||
func appendFrontmatter(front []byte, body string) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("---\n")
|
||||
buf.Write(bytes.TrimSpace(front))
|
||||
buf.WriteString("\n---\n")
|
||||
buf.WriteString(strings.TrimLeft(body, "\n"))
|
||||
if !strings.HasSuffix(buf.String(), "\n") {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func inferPerson(p *model.Person, path string) {
|
||||
if p.ID == "" {
|
||||
p.ID = "person_" + uuid.NewString()
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
p.Name = nameFromBody(p.Body)
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
p.Name = strings.ReplaceAll(model.PathSlug(path), "-", " ")
|
||||
}
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = fileTime(path)
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = fileTime(path)
|
||||
}
|
||||
if p.Accounts == nil {
|
||||
p.Accounts = map[string][]string{}
|
||||
}
|
||||
}
|
||||
|
||||
func inferNote(n *model.Note, path string) {
|
||||
if n.ID == "" {
|
||||
n.ID = "note_" + uuid.NewString()
|
||||
}
|
||||
if n.OccurredAt.IsZero() {
|
||||
n.OccurredAt = fileTime(path)
|
||||
}
|
||||
if n.CapturedAt.IsZero() {
|
||||
n.CapturedAt = fileTime(path)
|
||||
}
|
||||
if n.Kind == "" {
|
||||
n.Kind = "note"
|
||||
}
|
||||
if n.Source == "" {
|
||||
n.Source = "manual"
|
||||
}
|
||||
if n.Confidence == "" {
|
||||
n.Confidence = "medium"
|
||||
}
|
||||
if n.Privacy == "" {
|
||||
n.Privacy = "normal"
|
||||
}
|
||||
}
|
||||
|
||||
type personFront struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
SortName string `yaml:"sort_name,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Emails []model.ContactValue `yaml:"emails,omitempty"`
|
||||
Phones []model.ContactValue `yaml:"phones,omitempty"`
|
||||
Accounts map[string][]string `yaml:"accounts,omitempty"`
|
||||
Apple *model.ExternalRef `yaml:"apple,omitempty"`
|
||||
Google *model.ExternalRef `yaml:"google,omitempty"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
UpdatedAt time.Time `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
func personFrontmatter(p model.Person) personFront {
|
||||
return personFront{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
SortName: p.SortName,
|
||||
Tags: p.Tags,
|
||||
Emails: p.Emails,
|
||||
Phones: p.Phones,
|
||||
Accounts: nonEmptyAccounts(p.Accounts),
|
||||
Apple: nonEmptyExternal(p.Apple),
|
||||
Google: nonEmptyExternal(p.Google),
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type noteFront struct {
|
||||
ID string `yaml:"id"`
|
||||
PersonID string `yaml:"person_id"`
|
||||
OccurredAt time.Time `yaml:"occurred_at"`
|
||||
CapturedAt time.Time `yaml:"captured_at"`
|
||||
Kind string `yaml:"kind"`
|
||||
Source string `yaml:"source"`
|
||||
Account string `yaml:"account,omitempty"`
|
||||
ExternalID string `yaml:"external_id,omitempty"`
|
||||
Direction string `yaml:"direction,omitempty"`
|
||||
Confidence string `yaml:"confidence,omitempty"`
|
||||
Topics []string `yaml:"topics,omitempty"`
|
||||
FollowUpAt *time.Time `yaml:"follow_up_at,omitempty"`
|
||||
Privacy string `yaml:"privacy,omitempty"`
|
||||
}
|
||||
|
||||
func noteFrontmatter(n model.Note) noteFront {
|
||||
return noteFront{
|
||||
ID: n.ID,
|
||||
PersonID: n.PersonID,
|
||||
OccurredAt: n.OccurredAt,
|
||||
CapturedAt: n.CapturedAt,
|
||||
Kind: n.Kind,
|
||||
Source: n.Source,
|
||||
Account: n.Account,
|
||||
ExternalID: n.ExternalID,
|
||||
Direction: n.Direction,
|
||||
Confidence: n.Confidence,
|
||||
Topics: n.Topics,
|
||||
FollowUpAt: nonZeroTime(n.FollowUpAt),
|
||||
Privacy: n.Privacy,
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyExternal(ref model.ExternalRef) *model.ExternalRef {
|
||||
if ref.ID == "" && ref.Resource == "" && ref.ETag == "" && ref.LastSeenAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &ref
|
||||
}
|
||||
|
||||
func nonEmptyAccounts(accounts map[string][]string) map[string][]string {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func nonZeroTime(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func nameFromBody(body string) string {
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if title, ok := strings.CutPrefix(line, "# "); ok {
|
||||
return strings.TrimSpace(title)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileTime(path string) time.Time {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return info.ModTime().UTC()
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func backupOriginal(path, repairRoot string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := strings.TrimPrefix(filepath.Clean(path), string(filepath.Separator))
|
||||
dest := filepath.Join(repairRoot, time.Now().UTC().Format("20060102T150405Z"), rel)
|
||||
if !strings.HasPrefix(dest, filepath.Clean(repairRoot)+string(filepath.Separator)) {
|
||||
return fmt.Errorf("repair backup escaped repair root: %s", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// #nosec G703 -- dest is constrained to repairRoot above.
|
||||
return os.WriteFile(dest, data, 0o600)
|
||||
}
|
||||
|
||||
func salvagePerson(front string) model.Person {
|
||||
var p model.Person
|
||||
values := salvageScalars(front)
|
||||
p.ID = values["id"]
|
||||
p.Name = values["name"]
|
||||
p.SortName = values["sort_name"]
|
||||
p.Tags = splitList(values["tags"])
|
||||
p.CreatedAt = parseTime(values["created_at"])
|
||||
p.UpdatedAt = parseTime(values["updated_at"])
|
||||
return p
|
||||
}
|
||||
|
||||
func salvageNote(front string) model.Note {
|
||||
var n model.Note
|
||||
values := salvageScalars(front)
|
||||
n.ID = values["id"]
|
||||
n.PersonID = values["person_id"]
|
||||
n.Kind = values["kind"]
|
||||
n.Source = values["source"]
|
||||
n.Account = values["account"]
|
||||
n.ExternalID = values["external_id"]
|
||||
n.Direction = values["direction"]
|
||||
n.Confidence = values["confidence"]
|
||||
n.Privacy = values["privacy"]
|
||||
n.Topics = splitList(values["topics"])
|
||||
n.OccurredAt = parseTime(values["occurred_at"])
|
||||
n.CapturedAt = parseTime(values["captured_at"])
|
||||
n.FollowUpAt = parseTime(values["follow_up_at"])
|
||||
return n
|
||||
}
|
||||
|
||||
func salvageScalars(front string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for line := range strings.SplitSeq(front, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if key != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
value = strings.TrimSpace(strings.Trim(value, "[]"))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(strings.TrimSpace(part), `"'`)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTime(value string) time.Time {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
t, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func NoteFileName(n model.Note) string {
|
||||
t := n.OccurredAt.UTC()
|
||||
if t.IsZero() {
|
||||
t = time.Now().UTC()
|
||||
}
|
||||
kind := "note"
|
||||
if strings.TrimSpace(n.Kind) != "" {
|
||||
kind = model.Slug(n.Kind)
|
||||
}
|
||||
if kind == "" || kind == "person" {
|
||||
kind = "note"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s.md", t.Format("2006-01-02T15-04-05Z"), kind)
|
||||
}
|
||||
204
internal/markdown/markdown_test.go
Normal file
204
internal/markdown/markdown_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func TestPersonRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "people", "ada", "person.md")
|
||||
now := time.Date(2026, 5, 8, 9, 15, 0, 0, time.UTC)
|
||||
p := NewPerson("Ada Lovelace", now)
|
||||
p.Tags = []string{"math"}
|
||||
p.Body = "# Ada Lovelace\n\nNotes."
|
||||
if err := WritePerson(path, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, report, err := ReadPerson(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Needed {
|
||||
t.Fatalf("unexpected repair report: %#v", report)
|
||||
}
|
||||
if got.ID != p.ID || got.Name != p.Name || strings.TrimSpace(got.Body) != strings.TrimSpace(p.Body) {
|
||||
t.Fatalf("roundtrip mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonRepairSalvagesBrokenFrontmatter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "people", "ada", "person.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "---\nid: person_1\nname: Ada Lovelace\ntags: [math\n---\n# Ada\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, report, err := ReadPerson(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Needed || p.ID != "person_1" || p.Name != "Ada Lovelace" {
|
||||
t.Fatalf("repair = %#v person = %#v", report, p)
|
||||
}
|
||||
if err := RepairPerson(path, filepath.Join(dir, ".clawdex", "repairs"), p, report, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repaired, _, err := ReadPerson(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(repaired.Body, "Recovered metadata") {
|
||||
t.Fatalf("missing recovered metadata in body: %q", repaired.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPersonMissingFrontmatterInfersNameFromHeading(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "people", "ada", "person.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("# Ada Heading\n\nBody"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, report, err := ReadPerson(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Needed || p.Name != "Ada Heading" || p.ID == "" {
|
||||
t.Fatalf("person=%#v report=%#v", p, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPersonMalformedDelimiterUsesSlug(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "people", "slug-name", "person.md")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("---\nname: Ada\n# Missing close"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, report, err := ReadPerson(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Needed || p.Name != "Missing close" {
|
||||
t.Fatalf("person=%#v report=%#v", p, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
now := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC)
|
||||
n := NewNote("person_1", "dm", "whatsapp", "hello", now, now, []string{"intro"})
|
||||
if err := WriteNote(path, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, report, err := ReadNote(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Needed || got.ID != n.ID || got.PersonID != "person_1" || strings.TrimSpace(got.Body) != "hello" {
|
||||
t.Fatalf("got %#v report %#v", got, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNoteRepairAndDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
body := "---\nid: note_1\nperson_id: person_1\noccurred_at: 2026-05-08\ncaptured_at: 2026-05-08T09:00:00Z\ntopics: [one, two\n---\nBody\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, report, err := ReadNote(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Needed || n.ID != "note_1" || n.Kind != "note" || n.Source != "manual" || n.Privacy != "normal" {
|
||||
t.Fatalf("note=%#v report=%#v", n, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontmatterHelpers(t *testing.T) {
|
||||
front, body, ok := splitFrontmatter([]byte("---\nid: x\n---"))
|
||||
if !ok || strings.TrimSpace(front) != "id: x" || body != "" {
|
||||
t.Fatalf("front=%q body=%q ok=%v", front, body, ok)
|
||||
}
|
||||
for _, value := range []string{"2026-05-08", "2026-05-08T09:00:00Z", "bad"} {
|
||||
_ = parseTime(value)
|
||||
}
|
||||
n := NewNote("p", "", "", "", time.Time{}, time.Time{}, nil)
|
||||
if n.Kind != "" || !n.OccurredAt.IsZero() {
|
||||
t.Fatalf("new note zero = %#v", n)
|
||||
}
|
||||
if got := NoteFileName(n); !strings.HasSuffix(got, "-note.md") {
|
||||
t.Fatalf("note file = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePersonOmitsEmptyStructsButKeepsNonEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "person.md")
|
||||
p := NewPerson("Ada", time.Now())
|
||||
p.Accounts = map[string][]string{"github": {"ada"}}
|
||||
p.Google.Resource = "people/c1"
|
||||
if err := WritePerson(path, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "accounts:") || !strings.Contains(text, "google:") || strings.Contains(text, "apple:") {
|
||||
t.Fatalf("frontmatter = %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoreRepairAndNoteBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
if err := os.WriteFile(path, []byte("body only"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, report, err := ReadNote(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Needed || n.Kind != "note" || strings.TrimSpace(n.Body) != "body only" {
|
||||
t.Fatalf("note=%#v report=%#v", n, report)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
n.FollowUpAt = now
|
||||
if err := WriteNote(path, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RepairPerson(path, filepath.Join(dir, "repairs"), modelPersonForTest(), RepairReport{}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nameFromBody("no heading") != "" {
|
||||
t.Fatal("expected no heading")
|
||||
}
|
||||
parentFile := filepath.Join(dir, "file")
|
||||
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(parentFile, "child"), []byte("x"), 0o600); err == nil {
|
||||
t.Fatal("expected atomic write mkdir error")
|
||||
}
|
||||
}
|
||||
|
||||
func modelPersonForTest() model.Person {
|
||||
return NewPerson("Ada", time.Now())
|
||||
}
|
||||
33
internal/match/match.go
Normal file
33
internal/match/match.go
Normal file
@ -0,0 +1,33 @@
|
||||
package match
|
||||
|
||||
import "github.com/openclaw/clawdex/internal/model"
|
||||
|
||||
type Candidate struct {
|
||||
PersonID string `json:"person_id"`
|
||||
Reason string `json:"reason"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
func CandidateFor(contact model.SourceContact, p model.Person) (Candidate, bool) {
|
||||
if contact.ExternalID != "" && (p.Apple.ID == contact.ExternalID || p.Google.Resource == contact.ExternalID) {
|
||||
return Candidate{PersonID: p.ID, Reason: "external_id", Score: 100}, true
|
||||
}
|
||||
for _, email := range contact.Emails {
|
||||
for _, existing := range p.Emails {
|
||||
if model.NormalizeEmail(email.Value) != "" && model.NormalizeEmail(email.Value) == model.NormalizeEmail(existing.Value) {
|
||||
return Candidate{PersonID: p.ID, Reason: "email", Score: 90}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, phone := range contact.Phones {
|
||||
for _, existing := range p.Phones {
|
||||
if model.NormalizePhone(phone.Value) != "" && model.NormalizePhone(phone.Value) == model.NormalizePhone(existing.Value) {
|
||||
return Candidate{PersonID: p.ID, Reason: "phone", Score: 80}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if model.NormalizeName(contact.Name) != "" && model.NormalizeName(contact.Name) == model.NormalizeName(p.Name) {
|
||||
return Candidate{PersonID: p.ID, Reason: "name", Score: 40}, true
|
||||
}
|
||||
return Candidate{}, false
|
||||
}
|
||||
39
internal/match/match_test.go
Normal file
39
internal/match/match_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package match
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func TestCandidateForPrefersEmail(t *testing.T) {
|
||||
candidate, ok := CandidateFor(
|
||||
model.SourceContact{Name: "Ada", Emails: []model.ContactValue{{Value: "ADA@example.com"}}},
|
||||
model.Person{ID: "person_1", Name: "Different", Emails: []model.ContactValue{{Value: "ada@example.com"}}},
|
||||
)
|
||||
if !ok || candidate.Reason != "email" || candidate.Score != 90 {
|
||||
t.Fatalf("candidate = %#v ok=%v", candidate, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateForAllReasons(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
contact model.SourceContact
|
||||
person model.Person
|
||||
reason string
|
||||
ok bool
|
||||
}{
|
||||
{"external", model.SourceContact{ExternalID: "a1"}, model.Person{ID: "p1", Apple: model.ExternalRef{ID: "a1"}}, "external_id", true},
|
||||
{"google", model.SourceContact{ExternalID: "people/c1"}, model.Person{ID: "p1", Google: model.ExternalRef{Resource: "people/c1"}}, "external_id", true},
|
||||
{"phone", model.SourceContact{Phones: []model.ContactValue{{Value: "+1 555"}}}, model.Person{ID: "p1", Phones: []model.ContactValue{{Value: "1555"}}}, "phone", true},
|
||||
{"name", model.SourceContact{Name: "Ada Lovelace"}, model.Person{ID: "p1", Name: " Ada Lovelace "}, "name", true},
|
||||
{"none", model.SourceContact{Name: "Ada"}, model.Person{ID: "p1", Name: "Grace"}, "", false},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
got, ok := CandidateFor(tt.contact, tt.person)
|
||||
if ok != tt.ok || got.Reason != tt.reason {
|
||||
t.Fatalf("%s: got %#v ok=%v", tt.name, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/model/normalize.go
Normal file
52
internal/model/normalize.go
Normal file
@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var slugDash = regexp.MustCompile(`-+`)
|
||||
|
||||
func Slug(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r), r == '-', r == '_', r == '\'', r == '.':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(slugDash.ReplaceAllString(b.String(), "-"), "-")
|
||||
if out == "" {
|
||||
return "person"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func NormalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
out = strings.TrimPrefix(out, "00")
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeName(name string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(name))), " ")
|
||||
}
|
||||
|
||||
func PathSlug(path string) string {
|
||||
return filepath.Base(filepath.Dir(path))
|
||||
}
|
||||
27
internal/model/normalize_test.go
Normal file
27
internal/model/normalize_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlugStable(t *testing.T) {
|
||||
if got := Slug("Sally O'Malley"); got != "sally-o-malley" {
|
||||
t.Fatalf("Slug = %q", got)
|
||||
}
|
||||
if got := NormalizePhone("+1 (415) 734-7847"); got != "14157347847" {
|
||||
t.Fatalf("NormalizePhone = %q", got)
|
||||
}
|
||||
if got := NormalizePhone("0043 664 104 2436"); got != "436641042436" {
|
||||
t.Fatalf("NormalizePhone 00 = %q", got)
|
||||
}
|
||||
if got := NormalizeEmail(" ADA@Example.COM "); got != "ada@example.com" {
|
||||
t.Fatalf("NormalizeEmail = %q", got)
|
||||
}
|
||||
if got := NormalizeName(" Ada Lovelace "); got != "ada lovelace" {
|
||||
t.Fatalf("NormalizeName = %q", got)
|
||||
}
|
||||
if got := PathSlug("/tmp/ada/person.md"); got != "ada" {
|
||||
t.Fatalf("PathSlug = %q", got)
|
||||
}
|
||||
if got := Slug("***"); got != "person" {
|
||||
t.Fatalf("empty Slug = %q", got)
|
||||
}
|
||||
}
|
||||
20
internal/model/source.go
Normal file
20
internal/model/source.go
Normal file
@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
type SourceContact struct {
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Emails []ContactValue `json:"emails,omitempty"`
|
||||
Phones []ContactValue `json:"phones,omitempty"`
|
||||
Accounts map[string][]string `json:"accounts,omitempty"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
}
|
||||
|
||||
type ImportChange struct {
|
||||
Action string `json:"action"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Source SourceContact `json:"source"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
63
internal/model/types.go
Normal file
63
internal/model/types.go
Normal file
@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ContactValue struct {
|
||||
Value string `json:"value" yaml:"value"`
|
||||
Label string `json:"label,omitempty" yaml:"label,omitempty"`
|
||||
Source string `json:"source,omitempty" yaml:"source,omitempty"`
|
||||
Primary bool `json:"primary,omitempty" yaml:"primary,omitempty"`
|
||||
}
|
||||
|
||||
type ExternalRef struct {
|
||||
ID string `json:"id,omitempty" yaml:"id,omitempty"`
|
||||
Resource string `json:"resource,omitempty" yaml:"resource,omitempty"`
|
||||
ETag string `json:"etag,omitempty" yaml:"etag,omitempty"`
|
||||
LastSeenAt time.Time `json:"last_seen_at,omitzero" yaml:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
SortName string `json:"sort_name,omitempty" yaml:"sort_name,omitempty"`
|
||||
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
|
||||
Emails []ContactValue `json:"emails,omitempty" yaml:"emails,omitempty"`
|
||||
Phones []ContactValue `json:"phones,omitempty" yaml:"phones,omitempty"`
|
||||
Accounts map[string][]string `json:"accounts,omitempty" yaml:"accounts,omitempty"`
|
||||
Apple ExternalRef `json:"apple,omitzero" yaml:"apple,omitempty"`
|
||||
Google ExternalRef `json:"google,omitzero" yaml:"google,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
||||
Path string `json:"path,omitempty" yaml:"-"`
|
||||
Body string `json:"body,omitempty" yaml:"-"`
|
||||
Extra map[string]map[string]any `json:"extra,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type Note struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
PersonID string `json:"person_id" yaml:"person_id"`
|
||||
OccurredAt time.Time `json:"occurred_at" yaml:"occurred_at"`
|
||||
CapturedAt time.Time `json:"captured_at" yaml:"captured_at"`
|
||||
Kind string `json:"kind" yaml:"kind"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Account string `json:"account,omitempty" yaml:"account,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty" yaml:"external_id,omitempty"`
|
||||
Direction string `json:"direction,omitempty" yaml:"direction,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty" yaml:"confidence,omitempty"`
|
||||
Topics []string `json:"topics,omitempty" yaml:"topics,omitempty"`
|
||||
FollowUpAt time.Time `json:"follow_up_at,omitzero" yaml:"follow_up_at,omitempty"`
|
||||
Privacy string `json:"privacy,omitempty" yaml:"privacy,omitempty"`
|
||||
Path string `json:"path,omitempty" yaml:"-"`
|
||||
Body string `json:"body,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type SearchHit struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Score int `json:"score"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp,omitzero"`
|
||||
}
|
||||
133
internal/repo/config.go
Normal file
133
internal/repo/config.go
Normal file
@ -0,0 +1,133 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
crawlconfig "github.com/openclaw/crawlkit/config"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultConfigEnv = "CLAWDEX_CONFIG"
|
||||
RepoEnv = "CLAWDEX_REPO"
|
||||
DefaultRemote = "https://github.com/steipete/backup-clawdex.git"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Version int `toml:"version" json:"version"`
|
||||
RepoPath string `toml:"repo_path" json:"repo_path"`
|
||||
Git GitConfig `toml:"git" json:"git"`
|
||||
Repair Repair `toml:"repair" json:"repair"`
|
||||
Google Google `toml:"google" json:"google"`
|
||||
Apple Apple `toml:"apple" json:"apple"`
|
||||
}
|
||||
|
||||
type GitConfig struct {
|
||||
Remote string `toml:"remote" json:"remote"`
|
||||
Branch string `toml:"branch" json:"branch"`
|
||||
AutoPull bool `toml:"auto_pull" json:"auto_pull"`
|
||||
AutoPush bool `toml:"auto_push" json:"auto_push"`
|
||||
}
|
||||
|
||||
type Repair struct {
|
||||
AutoRepair bool `toml:"auto_repair" json:"auto_repair"`
|
||||
BackupBeforeRepair bool `toml:"backup_before_repair" json:"backup_before_repair"`
|
||||
}
|
||||
|
||||
type Google struct {
|
||||
DefaultAccount string `toml:"default_account" json:"default_account"`
|
||||
Adapter string `toml:"adapter" json:"adapter"`
|
||||
}
|
||||
|
||||
type Apple struct {
|
||||
Enabled bool `toml:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
var appConfig = crawlconfig.App{Name: "clawdex", ConfigEnv: DefaultConfigEnv, BaseDir: "~/.clawdex"}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
paths, err := appConfig.DefaultPaths()
|
||||
if err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
paths.ShareDir = filepath.Join(home, ".clawdex", "contacts")
|
||||
}
|
||||
return Config{
|
||||
Version: 1,
|
||||
RepoPath: paths.ShareDir,
|
||||
Git: GitConfig{
|
||||
Remote: DefaultRemote,
|
||||
Branch: "main",
|
||||
},
|
||||
Repair: Repair{
|
||||
AutoRepair: true,
|
||||
BackupBeforeRepair: true,
|
||||
},
|
||||
Google: Google{Adapter: "gog"},
|
||||
Apple: Apple{Enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveConfigPath(flagPath string) string {
|
||||
path, err := appConfig.ResolveConfigPath(flagPath)
|
||||
if err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".clawdex", "config.toml")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
if _, err := os.Stat(crawlconfig.ExpandHome(path)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
cfg.Normalize()
|
||||
return cfg, nil
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
if err := crawlconfig.LoadTOML(path, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Normalize()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func WriteConfig(path string, cfg Config) error {
|
||||
cfg.Normalize()
|
||||
return crawlconfig.WriteTOML(path, cfg, 0o600)
|
||||
}
|
||||
|
||||
func (c *Config) Normalize() {
|
||||
def := DefaultConfig()
|
||||
if c.Version == 0 {
|
||||
c.Version = 1
|
||||
}
|
||||
if strings.TrimSpace(c.RepoPath) == "" {
|
||||
c.RepoPath = def.RepoPath
|
||||
}
|
||||
c.RepoPath = crawlconfig.ExpandHome(c.RepoPath)
|
||||
if strings.TrimSpace(c.Git.Remote) == "" {
|
||||
c.Git.Remote = def.Git.Remote
|
||||
}
|
||||
if strings.TrimSpace(c.Git.Branch) == "" {
|
||||
c.Git.Branch = "main"
|
||||
}
|
||||
if strings.TrimSpace(c.Google.Adapter) == "" {
|
||||
c.Google.Adapter = "gog"
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveRepoPath(flagRepo string, cfg Config) (string, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(flagRepo) != "":
|
||||
return crawlconfig.ExpandHome(flagRepo), nil
|
||||
case strings.TrimSpace(os.Getenv(RepoEnv)) != "":
|
||||
return crawlconfig.ExpandHome(os.Getenv(RepoEnv)), nil
|
||||
case strings.TrimSpace(cfg.RepoPath) != "":
|
||||
return crawlconfig.ExpandHome(cfg.RepoPath), nil
|
||||
default:
|
||||
return "", errors.New("contacts repo not configured; run clawdex init DIR or pass --repo DIR")
|
||||
}
|
||||
}
|
||||
27
internal/repo/git.go
Normal file
27
internal/repo/git.go
Normal file
@ -0,0 +1,27 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openclaw/crawlkit/mirror"
|
||||
)
|
||||
|
||||
func (r Repo) MirrorOptions() mirror.Options {
|
||||
return mirror.Options{RepoPath: r.Path, Remote: r.Config.Git.Remote, Branch: r.Config.Git.Branch}
|
||||
}
|
||||
|
||||
func (r Repo) Pull(ctx context.Context) error {
|
||||
return mirror.PullCurrent(ctx, r.MirrorOptions())
|
||||
}
|
||||
|
||||
func (r Repo) Push(ctx context.Context) error {
|
||||
return mirror.Push(ctx, r.MirrorOptions())
|
||||
}
|
||||
|
||||
func (r Repo) Commit(ctx context.Context, message string) (bool, error) {
|
||||
return mirror.Commit(ctx, r.MirrorOptions(), message)
|
||||
}
|
||||
|
||||
func (r Repo) Dirty(ctx context.Context) (bool, error) {
|
||||
return mirror.Dirty(ctx, r.MirrorOptions())
|
||||
}
|
||||
83
internal/repo/repo.go
Normal file
83
internal/repo/repo.go
Normal file
@ -0,0 +1,83 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/crawlkit/mirror"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Path string
|
||||
Config Config
|
||||
}
|
||||
|
||||
func Open(path string, cfg Config) Repo {
|
||||
return Repo{Path: path, Config: cfg}
|
||||
}
|
||||
|
||||
func (r Repo) Init(ctx context.Context) error {
|
||||
if strings.TrimSpace(r.Path) == "" {
|
||||
return errors.New("repo path is required")
|
||||
}
|
||||
for _, dir := range []string{
|
||||
filepath.Join(r.Path, "people"),
|
||||
filepath.Join(r.Path, "index"),
|
||||
filepath.Join(r.Path, ".clawdex", "repairs"),
|
||||
} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := mirror.EnsureRepo(ctx, mirror.Options{RepoPath: r.Path, Branch: r.Config.Git.Branch}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDataConfig(filepath.Join(r.Path, "clawdex.toml"), r.Config); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(r.Config.Git.Remote) != "" {
|
||||
if err := mirror.EnsureRemote(ctx, mirror.Options{RepoPath: r.Path, Remote: r.Config.Git.Remote, Branch: r.Config.Git.Branch}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Repo) Require() error {
|
||||
if strings.TrimSpace(r.Path) == "" {
|
||||
return errors.New("repo path is required")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.Path, "people")); err != nil {
|
||||
return fmt.Errorf("contacts repo not initialized at %s; run clawdex init %s", r.Path, r.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Repo) PeopleDir() string {
|
||||
return filepath.Join(r.Path, "people")
|
||||
}
|
||||
|
||||
func (r Repo) IndexDir() string {
|
||||
return filepath.Join(r.Path, "index")
|
||||
}
|
||||
|
||||
func (r Repo) RepairDir() string {
|
||||
return filepath.Join(r.Path, ".clawdex", "repairs")
|
||||
}
|
||||
|
||||
func writeDataConfig(path string, cfg Config) error {
|
||||
data := []byte("version = 1\n")
|
||||
if strings.TrimSpace(cfg.Git.Remote) != "" {
|
||||
data = append(data, []byte("\n[git]\nremote = \""+escapeTOML(cfg.Git.Remote)+"\"\nbranch = \""+escapeTOML(cfg.Git.Branch)+"\"\n")...)
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func escapeTOML(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
return strings.ReplaceAll(s, "\"", "\\\"")
|
||||
}
|
||||
172
internal/repo/repo_test.go
Normal file
172
internal/repo/repo_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigLoadWriteAndResolve(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
cfg := DefaultConfig()
|
||||
cfg.RepoPath = filepath.Join(dir, "contacts")
|
||||
cfg.Git.Remote = ""
|
||||
if err := WriteConfig(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RepoPath != cfg.RepoPath || loaded.Git.Branch != "main" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
if got, err := ResolveRepoPath("", loaded); err != nil || got != cfg.RepoPath {
|
||||
t.Fatalf("repo path = %q err=%v", got, err)
|
||||
}
|
||||
t.Setenv(RepoEnv, filepath.Join(dir, "envrepo"))
|
||||
if got, _ := ResolveRepoPath("", loaded); !strings.HasSuffix(got, "envrepo") {
|
||||
t.Fatalf("env repo path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFillsDefaults(t *testing.T) {
|
||||
cfg := Config{}
|
||||
cfg.Normalize()
|
||||
if cfg.Version != 1 || cfg.RepoPath == "" || cfg.Git.Remote != DefaultRemote || cfg.Git.Branch != "main" || cfg.Google.Adapter != "gog" {
|
||||
t.Fatalf("cfg = %#v", cfg)
|
||||
}
|
||||
got, err := ResolveRepoPath("/tmp/direct", cfg)
|
||||
if err != nil || got != "/tmp/direct" {
|
||||
t.Fatalf("direct repo = %q err=%v", got, err)
|
||||
}
|
||||
t.Setenv(RepoEnv, "")
|
||||
if _, err := ResolveRepoPath("", Config{}); err == nil {
|
||||
t.Fatal("expected empty repo config error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMissingAndBad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.toml")
|
||||
cfg, err := LoadConfig(missing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.Remote != DefaultRemote {
|
||||
t.Fatalf("cfg = %#v", cfg)
|
||||
}
|
||||
bad := filepath.Join(dir, "bad.toml")
|
||||
if err := os.WriteFile(bad, []byte("["), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(bad); err == nil {
|
||||
t.Fatal("expected bad config error")
|
||||
}
|
||||
if got := ResolveConfigPath(missing); got != missing {
|
||||
t.Fatalf("config path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInitRequireAndGit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.RepoPath = dir
|
||||
cfg.Git.Remote = ""
|
||||
r := Open(dir, cfg)
|
||||
if err := r.Init(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{r.PeopleDir(), r.IndexDir(), r.RepairDir(), filepath.Join(dir, ".git"), filepath.Join(dir, "clawdex.toml")} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("missing %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if err := r.Require(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts := r.MirrorOptions(); opts.RepoPath != dir || opts.Branch != "main" {
|
||||
t.Fatalf("opts = %#v", opts)
|
||||
}
|
||||
dirty, err := r.Dirty(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dirty {
|
||||
t.Fatal("expected dirty repo after init")
|
||||
}
|
||||
committed, err := r.Commit(t.Context(), "test: init")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !committed {
|
||||
t.Fatal("expected commit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoGuardErrors(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if err := Open("", cfg).Init(t.Context()); err == nil {
|
||||
t.Fatal("expected empty init path error")
|
||||
}
|
||||
if err := Open("", cfg).Require(); err == nil {
|
||||
t.Fatal("expected empty require path error")
|
||||
}
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
if err := Open(missing, cfg).Require(); err == nil || !strings.Contains(err.Error(), "not initialized") {
|
||||
t.Fatalf("require missing err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireFailsBeforeInit(t *testing.T) {
|
||||
err := Open(t.TempDir(), DefaultConfig()).Require()
|
||||
if err == nil || !strings.Contains(err.Error(), "not initialized") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got := escapeTOML(`a"b\c`); got != `a\"b\\c` {
|
||||
t.Fatalf("escaped = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInitGuardsAndRemoteLocal(t *testing.T) {
|
||||
if err := Open("", DefaultConfig()).Init(t.Context()); err == nil {
|
||||
t.Fatal("expected empty path error")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
remote := filepath.Join(dir, "remote.git")
|
||||
if err := os.Mkdir(remote, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, remote, "init", "--bare")
|
||||
cfg := DefaultConfig()
|
||||
cfg.RepoPath = filepath.Join(dir, "work")
|
||||
cfg.Git.Remote = remote
|
||||
r := Open(cfg.RepoPath, cfg)
|
||||
if err := r.Init(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cfg.RepoPath, "people", "x"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if committed, err := r.Commit(t.Context(), "test: remote"); err != nil || !committed {
|
||||
t.Fatalf("commit=%v err=%v", committed, err)
|
||||
}
|
||||
if err := r.Push(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Pull(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
108
internal/vcard/vcard.go
Normal file
108
internal/vcard/vcard.go
Normal file
@ -0,0 +1,108 @@
|
||||
package vcard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func Write(w io.Writer, people []model.Person) error {
|
||||
for _, p := range people {
|
||||
if err := writeOne(w, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeOne(w io.Writer, p model.Person) error {
|
||||
lines := []string{
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"UID:" + escape(p.ID),
|
||||
"FN:" + escape(p.Name),
|
||||
"N:" + structuredName(p),
|
||||
}
|
||||
for _, email := range p.Emails {
|
||||
if strings.TrimSpace(email.Value) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "EMAIL"+typeParam(email.Label)+":"+escape(email.Value))
|
||||
}
|
||||
for _, phone := range p.Phones {
|
||||
if strings.TrimSpace(phone.Value) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "TEL"+typeParam(phone.Label)+":"+escape(phone.Value))
|
||||
}
|
||||
if len(p.Tags) > 0 {
|
||||
lines = append(lines, "CATEGORIES:"+escape(strings.Join(p.Tags, ",")))
|
||||
}
|
||||
lines = append(lines, "NOTE:"+escape("clawdex:"+p.ID))
|
||||
lines = append(lines, "END:VCARD")
|
||||
for _, line := range lines {
|
||||
if err := folded(w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func structuredName(p model.Person) string {
|
||||
name := strings.Fields(p.Name)
|
||||
if len(name) == 0 {
|
||||
return ";;;;"
|
||||
}
|
||||
if len(name) == 1 {
|
||||
return escape(name[0]) + ";;;;"
|
||||
}
|
||||
family := name[len(name)-1]
|
||||
given := strings.Join(name[:len(name)-1], " ")
|
||||
return escape(family) + ";" + escape(given) + ";;;"
|
||||
}
|
||||
|
||||
func typeParam(label string) string {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label == "" {
|
||||
return ""
|
||||
}
|
||||
label = strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, label)
|
||||
if label == "" {
|
||||
return ""
|
||||
}
|
||||
return ";TYPE=" + label
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, ";", "\\;")
|
||||
s = strings.ReplaceAll(s, ",", "\\,")
|
||||
return s
|
||||
}
|
||||
|
||||
func folded(w io.Writer, line string) error {
|
||||
const limit = 75
|
||||
for len(line) > limit {
|
||||
cut := limit
|
||||
for !utf8.ValidString(line[:cut]) {
|
||||
cut--
|
||||
}
|
||||
if _, err := fmt.Fprint(w, line[:cut]+"\r\n "); err != nil {
|
||||
return err
|
||||
}
|
||||
line = line[cut:]
|
||||
}
|
||||
_, err := fmt.Fprint(w, line+"\r\n")
|
||||
return err
|
||||
}
|
||||
80
internal/vcard/vcard_test.go
Normal file
80
internal/vcard/vcard_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package vcard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/openclaw/clawdex/internal/model"
|
||||
)
|
||||
|
||||
func TestWriteVCard(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
person := model.Person{
|
||||
ID: "person_1",
|
||||
Name: "Ada Lovelace",
|
||||
Tags: []string{"math"},
|
||||
Emails: []model.ContactValue{{Value: "ada@example.com", Label: "home"}},
|
||||
Phones: []model.ContactValue{{Value: "+1 555 0100", Label: "mobile"}},
|
||||
}
|
||||
if err := Write(&buf, []model.Person{person}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"BEGIN:VCARD", "UID:person_1", "FN:Ada Lovelace", "EMAIL;TYPE=home:ada@example.com", "TEL;TYPE=mobile:+1 555 0100", "NOTE:clawdex:person_1"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCardHelpers(t *testing.T) {
|
||||
if got := structuredName(model.Person{Name: "Ada Lovelace"}); got != "Lovelace;Ada;;;" {
|
||||
t.Fatalf("structured = %q", got)
|
||||
}
|
||||
if got := structuredName(model.Person{}); got != ";;;;" {
|
||||
t.Fatalf("empty structured = %q", got)
|
||||
}
|
||||
if got := typeParam("Mobile Phone!"); got != ";TYPE=mobilephone" {
|
||||
t.Fatalf("type = %q", got)
|
||||
}
|
||||
if got := typeParam("!!!"); got != "" {
|
||||
t.Fatalf("invalid type = %q", got)
|
||||
}
|
||||
if got := escape("a,b;c\\d\ne"); got != `a\,b\;c\\d\ne` {
|
||||
t.Fatalf("escape = %q", got)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := folded(&buf, strings.Repeat("a", 90)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "\r\n ") {
|
||||
t.Fatalf("not folded: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSkipsEmptyValuesAndEmptyList(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Write(&buf, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("buf = %q", buf.String())
|
||||
}
|
||||
err := Write(errWriter{}, []model.Person{{ID: "p", Name: "A"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected writer error")
|
||||
}
|
||||
buf.Reset()
|
||||
if err := Write(&buf, []model.Person{{ID: "p", Name: "Solo", Emails: []model.ContactValue{{}}, Phones: []model.ContactValue{{}}, Tags: []string{"one", "two"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "EMAIL") || strings.Contains(buf.String(), "TEL") || !strings.Contains(buf.String(), "CATEGORIES:one\\,two") {
|
||||
t.Fatalf("vcard = %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
type errWriter struct{}
|
||||
|
||||
func (errWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
Loading…
Reference in New Issue
Block a user