803 lines
21 KiB
Go
803 lines
21 KiB
Go
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/avatar"
|
|
"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"`
|
|
Avatar PersonAvatarCmd `cmd:"" help:"Manage person avatars"`
|
|
}
|
|
|
|
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 PersonAvatarCmd struct {
|
|
Set PersonAvatarSetCmd `cmd:"" help:"Set a local avatar image"`
|
|
Show PersonAvatarShowCmd `cmd:"" help:"Show avatar metadata"`
|
|
Clear PersonAvatarClearCmd `cmd:"" help:"Clear avatar metadata"`
|
|
}
|
|
|
|
type PersonAvatarSetCmd struct {
|
|
Person string `arg:"" help:"Person query"`
|
|
File string `arg:"" help:"Image file"`
|
|
}
|
|
|
|
func (c *PersonAvatarSetCmd) Run(r *Runtime) error {
|
|
p, err := r.store.FindPerson(c.Person)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.root.DryRun {
|
|
ref, err := avatar.InspectFile(c.File)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ref.Path = "avatars/avatar"
|
|
ref.Source = "manual"
|
|
return r.print(map[string]any{"would_set_avatar": p.ID, "mime": ref.MIME, "sha256": ref.SHA256})
|
|
}
|
|
p, err = r.store.SetAvatar(c.Person, c.File, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.print(p.Avatar)
|
|
}
|
|
|
|
type PersonAvatarShowCmd struct {
|
|
Person string `arg:"" help:"Person query"`
|
|
Path bool `name:"path" help:"Print absolute avatar path only"`
|
|
}
|
|
|
|
func (c *PersonAvatarShowCmd) Run(r *Runtime) error {
|
|
p, err := r.store.FindPerson(c.Person)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p.Avatar.Path == "" {
|
|
return fmt.Errorf("%s has no avatar", p.Name)
|
|
}
|
|
if c.Path {
|
|
path, err := avatar.AbsolutePath(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(r.stdout, path)
|
|
return err
|
|
}
|
|
return r.print(p.Avatar)
|
|
}
|
|
|
|
type PersonAvatarClearCmd struct {
|
|
Person string `arg:"" help:"Person query"`
|
|
}
|
|
|
|
func (c *PersonAvatarClearCmd) Run(r *Runtime) error {
|
|
p, err := r.store.FindPerson(c.Person)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.root.DryRun {
|
|
return r.print(map[string]any{"would_clear_avatar": p.ID})
|
|
}
|
|
p, err = r.store.ClearAvatar(c.Person, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.printPerson(p)
|
|
}
|
|
|
|
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"`
|
|
Avatars bool `name:"avatars" help:"Import local avatar thumbnails"`
|
|
}
|
|
|
|
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, c.Avatars), 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"`
|
|
Avatars bool `name:"avatars" help:"Fetch Google contact avatar bytes through gog raw photo URLs"`
|
|
}
|
|
|
|
func (c *ImportGoogleCmd) Run(r *Runtime) error {
|
|
account := c.Account
|
|
if account == "" {
|
|
account = r.cfg.Google.DefaultAccount
|
|
}
|
|
contacts, err := (google.GogAdapter{}).ListContactsWithOptions(r.ctx, account, google.Options{IncludeAvatars: c.Avatars})
|
|
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"`
|
|
IncludeAvatars bool `name:"include-avatars" help:"Include avatar PHOTO fields"`
|
|
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.WriteWithOptions(r.stdout, people, vcard.Options{IncludeAvatars: c.IncludeAvatars})
|
|
}
|
|
f, err := os.Create(c.Out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
if err := vcard.WriteWithOptions(f, people, vcard.Options{IncludeAvatars: c.IncludeAvatars}); 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,
|
|
}
|
|
avatarProblems := 0
|
|
for _, p := range people {
|
|
avatarProblems += len(avatar.Validate(p))
|
|
}
|
|
if avatarProblems > 0 {
|
|
result["avatar_problems"] = avatarProblems
|
|
}
|
|
if c.Repair {
|
|
var repaired int
|
|
var avatarRepaired 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
|
|
}
|
|
}
|
|
}
|
|
if len(avatar.Validate(loaded)) > 0 {
|
|
avatarRepaired++
|
|
if !r.root.DryRun {
|
|
_, _, err := store.RepairAvatarMetadata(loaded, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result["repaired"] = repaired
|
|
result["avatar_repaired"] = avatarRepaired
|
|
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 ""
|
|
}
|