diff --git a/cli/cmd_conferences.go b/cli/cmd_conferences.go new file mode 100644 index 0000000..659b278 --- /dev/null +++ b/cli/cmd_conferences.go @@ -0,0 +1,27 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func (a *App) conferencesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "conferences", + Short: "List available CVF conferences", + Long: `List all conferences available in the CVF open access repository. + +Examples: + cvf conferences + cvf conferences -f json + cvf conferences --fields name,year,location`, + RunE: func(cmd *cobra.Command, _ []string) error { + a.progressf("fetching conference list...") + confs, err := a.client.Conferences(cmd.Context()) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(confs, len(confs)) + }, + } + return cmd +} diff --git a/cli/cmd_papers.go b/cli/cmd_papers.go new file mode 100644 index 0000000..3256785 --- /dev/null +++ b/cli/cmd_papers.go @@ -0,0 +1,36 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func (a *App) papersCmd() *cobra.Command { + var conference string + var year int + + cmd := &cobra.Command{ + Use: "papers", + Short: "List papers from a CVF conference", + Long: `List papers from a CVF open access conference. + +Defaults to CVPR 2024 when no flags are given. + +Examples: + cvf papers + cvf papers --conference CVPR --year 2024 --limit 20 + cvf papers --conference ICCV --year 2023 -f json`, + RunE: func(cmd *cobra.Command, _ []string) error { + n := a.effectiveLimit(20) + a.progressf("fetching papers from %s %d...", conference, year) + papers, err := a.client.Papers(cmd.Context(), conference, year, n) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(papers, len(papers)) + }, + } + + cmd.Flags().StringVarP(&conference, "conference", "c", "CVPR", "conference name (CVPR, ICCV, ECCV, WACV)") + cmd.Flags().IntVarP(&year, "year", "y", 2024, "conference year") + return cmd +} diff --git a/cli/cmd_search.go b/cli/cmd_search.go new file mode 100644 index 0000000..81b7568 --- /dev/null +++ b/cli/cmd_search.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func (a *App) searchCmd() *cobra.Command { + var conference string + var year int + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search papers by title or author", + Long: `Search papers from a CVF conference by title or author name. + +The query is matched case-insensitively against each paper's title and author list. + +Examples: + cvf search "object detection" + cvf search --conference ICCV --year 2023 "transformer" --limit 10 + cvf search "LeCun" --conference CVPR --year 2023`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := args[0] + n := a.effectiveLimit(0) + a.progressf("searching %s %d for %q...", conference, year, query) + papers, err := a.client.Search(cmd.Context(), query, conference, year, n) + if err != nil { + return mapFetchErr(err) + } + if len(papers) == 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "no papers matched %q\n", query) + return codeError(exitNoData, nil) + } + return a.renderOrEmpty(papers, len(papers)) + }, + } + + cmd.Flags().StringVarP(&conference, "conference", "c", "CVPR", "conference name (CVPR, ICCV, ECCV, WACV)") + cmd.Flags().IntVarP(&year, "year", "y", 2024, "conference year") + return cmd +} diff --git a/cli/errors.go b/cli/errors.go new file mode 100644 index 0000000..d60545f --- /dev/null +++ b/cli/errors.go @@ -0,0 +1,11 @@ +package cli + +import ( + "errors" + + "github.com/tamnd/cvf-cli/cvf" +) + +func isNotFound(err error) bool { + return errors.Is(err, cvf.ErrNotFound) +} diff --git a/cli/output.go b/cli/output.go new file mode 100644 index 0000000..8f4bcd7 --- /dev/null +++ b/cli/output.go @@ -0,0 +1,25 @@ +package cli + +import ( + "io" + + "github.com/tamnd/cvf-cli/pkg/render" +) + +// Format aliases so command code reads cleanly. +type Format = render.Format + +const ( + FormatTable = render.FormatTable + FormatJSON = render.FormatJSON + FormatJSONL = render.FormatJSONL + FormatCSV = render.FormatCSV + FormatTSV = render.FormatTSV + FormatURL = render.FormatURL + FormatRaw = render.FormatRaw +) + +// NewRenderer builds a renderer writing to w. +func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *render.Renderer { + return render.New(w, format, fields, noHeader, tmpl) +} diff --git a/cli/root.go b/cli/root.go index 54e84b4..e26fbee 100644 --- a/cli/root.go +++ b/cli/root.go @@ -2,7 +2,12 @@ package cli import ( + "fmt" + "os" + + "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "github.com/tamnd/cvf-cli/cvf" ) // Build metadata, set via -ldflags at release time. @@ -12,20 +17,135 @@ var ( Date = "unknown" ) +// exit codes. +const ( + exitError = 1 + exitUsage = 2 + exitNoData = 3 +) + +// ExitError carries a process exit code up to main. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("exit %d", e.Code) +} + +func (e *ExitError) Unwrap() error { return e.Err } + +func codeError(code int, err error) error { return &ExitError{Code: code, Err: err} } + +// App holds shared state threaded through every command. +type App struct { + client *cvf.Client + cfg cvf.Config + + output string + fields []string + noHeader bool + template string + limit int + quiet bool +} + // Root builds the root command and its subtree. func Root() *cobra.Command { + app := &App{cfg: cvf.DefaultConfig()} + root := &cobra.Command{ Use: "cvf", Short: "Browse Computer Vision Foundation conference papers", - Long: `Browse Computer Vision Foundation conference papers + Long: `cvf reads the CVF open access repository at https://openaccess.thecvf.com/ +It lists papers from CVPR, ICCV, ECCV, WACV and other CVF conferences. +No API key required — the site is open access. -This is a fresh scaffold. Add your commands here on top of the cvf -library package, then wire them into Root with root.AddCommand.`, +cvf is an independent tool and is not affiliated with the CVF or IEEE.`, SilenceUsage: true, SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return app.setup() + }, } - root.AddCommand(newVersionCmd()) - // TODO: root.AddCommand(newGetCmd()), etc. + pf := root.PersistentFlags() + pf.StringVarP(&app.output, "format", "f", "auto", "output format: table|json|jsonl|csv|tsv|url (auto=table on TTY, jsonl piped)") + pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include") + pf.BoolVar(&app.noHeader, "no-header", false, "omit the header row in table/csv/tsv") + pf.StringVar(&app.template, "template", "", "Go text/template applied per record") + pf.IntVarP(&app.limit, "limit", "n", 0, "limit number of records (0 = command default)") + pf.BoolVarP(&app.quiet, "quiet", "q", false, "suppress progress on stderr") + + pf.StringVar(&app.cfg.BaseURL, "base-url", app.cfg.BaseURL, "CVF open access base URL") + pf.DurationVar(&app.cfg.Rate, "delay", app.cfg.Rate, "minimum spacing between requests") + pf.DurationVar(&app.cfg.Timeout, "timeout", app.cfg.Timeout, "per-request timeout") + pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx") + pf.StringVar(&app.cfg.UserAgent, "user-agent", app.cfg.UserAgent, "User-Agent sent with each request") + + root.AddCommand( + app.papersCmd(), + app.searchCmd(), + app.conferencesCmd(), + newVersionCmd(), + ) return root } + +func (a *App) setup() error { + if a.output == "" || a.output == "auto" { + if isatty.IsTerminal(os.Stdout.Fd()) { + a.output = string(FormatTable) + } else { + a.output = string(FormatJSONL) + } + } + if !Format(a.output).Valid() { + return codeError(exitUsage, fmt.Errorf("unknown output format %q", a.output)) + } + a.client = cvf.NewClient(a.cfg) + return nil +} + +func (a *App) render(records any) error { + r := NewRenderer(os.Stdout, Format(a.output), a.fields, a.noHeader, a.template) + return r.Render(records) +} + +func (a *App) renderOrEmpty(records any, n int) error { + if err := a.render(records); err != nil { + return err + } + if n == 0 { + return codeError(exitNoData, nil) + } + return nil +} + +func (a *App) progressf(format string, args ...any) { + if a.quiet { + return + } + fmt.Fprintf(os.Stderr, format+"\n", args...) +} + +func mapFetchErr(err error) error { + if err == nil { + return nil + } + if isNotFound(err) { + return codeError(exitNoData, err) + } + return codeError(exitError, err) +} + +func (a *App) effectiveLimit(def int) int { + if a.limit > 0 { + return a.limit + } + return def +} diff --git a/cvf/cvf.go b/cvf/cvf.go index 696523b..a69d5a2 100644 --- a/cvf/cvf.go +++ b/cvf/cvf.go @@ -1,10 +1,10 @@ -// Package cvf is the library behind the cvf command line: -// the HTTP client, request shaping, and the typed data models for cvf. +// Package cvf is the library behind the cvf command: the HTTP client, +// request shaping, and the typed data models for Computer Vision Foundation +// open access papers at https://openaccess.thecvf.com/ // -// The Client here is the spine every command shares. It sets a real -// User-Agent, paces requests so a busy session stays polite, and retries the -// transient failures (429 and 5xx) that any public site throws under load. -// Build your endpoint calls and JSON decoding on top of it. +// The site lists papers from conferences like CVPR, ICCV, ECCV, and WACV. +// Pages use plain HTML with no JavaScript required for the paper listing, +// so all parsing uses stdlib strings — no external HTML parser needed. package cvf import ( @@ -12,41 +12,154 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) -// DefaultUserAgent identifies the client to cvf. A real, honest -// User-Agent is both polite and the thing most likely to keep you unblocked. +// DefaultUserAgent identifies the client to the CVF site. const DefaultUserAgent = "cvf/dev (+https://github.com/tamnd/cvf-cli)" -// Client talks to cvf over HTTP. -type Client struct { - HTTP *http.Client +// DefaultBaseURL is the CVF open access root. +const DefaultBaseURL = "https://openaccess.thecvf.com" + +// ErrNotFound is returned when a conference or year produces no papers. +var ErrNotFound = fmt.Errorf("not found") + +// Config holds constructor parameters for a Client. +type Config struct { + BaseURL string UserAgent string - // Rate is the minimum gap between requests. Zero means no pacing. - Rate time.Duration - Retries int + Rate time.Duration + Retries int + Timeout time.Duration +} - last time.Time +// DefaultConfig returns sensible defaults. +func DefaultConfig() Config { + return Config{ + BaseURL: DefaultBaseURL, + UserAgent: DefaultUserAgent, + Rate: 300 * time.Millisecond, + Retries: 3, + Timeout: 30 * time.Second, + } } -// NewClient returns a Client with sensible defaults: a 30s timeout, a 200ms -// minimum gap between requests, and five retries on transient errors. -func NewClient() *Client { +// Client talks to the CVF open access site. +type Client struct { + baseURL string + http *http.Client + userAgent string + rate time.Duration + retries int + last time.Time +} + +// NewClient returns a Client built from cfg. +func NewClient(cfg Config) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = DefaultBaseURL + } + if cfg.UserAgent == "" { + cfg.UserAgent = DefaultUserAgent + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } return &Client{ - HTTP: &http.Client{Timeout: 30 * time.Second}, - UserAgent: DefaultUserAgent, - Rate: 200 * time.Millisecond, - Retries: 5, + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + http: &http.Client{Timeout: cfg.Timeout}, + userAgent: cfg.UserAgent, + rate: cfg.Rate, + retries: cfg.Retries, + } +} + +// Paper represents a single CVF open access paper. +type Paper struct { + Title string `json:"title"` + Authors []string `json:"authors"` + Conference string `json:"conference"` + Year int `json:"year"` + PDFURL string `json:"pdf_url"` + AbstractURL string `json:"abstract_url"` +} + +// Conference represents a conference entry from the menu. +type Conference struct { + Name string `json:"name"` + Year int `json:"year"` + Location string `json:"location"` + URL string `json:"url"` +} + +// Papers fetches papers from the given conference and year. Pass limit=0 for all. +// Defaults: conference="CVPR", year=2024. +func (c *Client) Papers(ctx context.Context, conference string, year int, limit int) ([]Paper, error) { + if conference == "" { + conference = "CVPR" + } + if year == 0 { + year = 2024 + } + key := fmt.Sprintf("%s%d", strings.ToUpper(conference), year) + rawURL := fmt.Sprintf("%s/%s?day=all", c.baseURL, key) + + body, err := c.get(ctx, rawURL) + if err != nil { + return nil, err + } + papers := parsePapers(string(body), strings.ToUpper(conference), year, c.baseURL) + if len(papers) == 0 { + return nil, ErrNotFound + } + if limit > 0 && limit < len(papers) { + papers = papers[:limit] } + return papers, nil } -// Get fetches url and returns the response body. It paces and retries according -// to the client's settings. The caller owns nothing extra; the body is read -// fully and closed here. -func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { +// Search returns papers whose title or authors contain query (case-insensitive). +func (c *Client) Search(ctx context.Context, query, conference string, year int, limit int) ([]Paper, error) { + all, err := c.Papers(ctx, conference, year, 0) + if err != nil { + return nil, err + } + q := strings.ToLower(query) + var out []Paper + for _, p := range all { + if strings.Contains(strings.ToLower(p.Title), q) { + out = append(out, p) + } else { + for _, a := range p.Authors { + if strings.Contains(strings.ToLower(a), q) { + out = append(out, p) + break + } + } + } + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +// Conferences fetches the list of available conferences from the menu page. +func (c *Client) Conferences(ctx context.Context) ([]Conference, error) { + rawURL := c.baseURL + "/menu" + body, err := c.get(ctx, rawURL) + if err != nil { + return nil, err + } + return parseConferences(string(body), c.baseURL), nil +} + +// ─── HTTP ───────────────────────────────────────────────────────────────────── + +func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) { var lastErr error - for attempt := 0; attempt <= c.Retries; attempt++ { + for attempt := 0; attempt <= c.retries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): @@ -54,7 +167,7 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { case <-time.After(backoff(attempt)): } } - body, retry, err := c.do(ctx, url) + body, retry, err := c.do(ctx, rawURL) if err == nil { return body, nil } @@ -63,18 +176,19 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { return nil, err } } - return nil, fmt.Errorf("get %s: %w", url, lastErr) + return nil, fmt.Errorf("get %s: %w", rawURL, lastErr) } -func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, err error) { +func (c *Client) do(ctx context.Context, rawURL string) ([]byte, bool, error) { c.pace() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, false, err } - req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,*/*") - resp, err := c.HTTP.Do(req) + resp, err := c.http.Do(req) if err != nil { return nil, true, err } @@ -86,20 +200,18 @@ func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, e if resp.StatusCode != http.StatusOK { return nil, false, fmt.Errorf("http %d", resp.StatusCode) } - - b, err := io.ReadAll(resp.Body) + b, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, true, err } return b, false, nil } -// pace blocks until at least Rate has passed since the previous request. func (c *Client) pace() { - if c.Rate <= 0 { + if c.rate <= 0 { return } - if wait := c.Rate - time.Since(c.last); wait > 0 { + if wait := c.rate - time.Since(c.last); wait > 0 { time.Sleep(wait) } c.last = time.Now() @@ -112,3 +224,241 @@ func backoff(attempt int) time.Duration { } return d } + +// ─── Parsing ────────────────────────────────────────────────────────────────── + +// parsePapers extracts Paper records from the CVF conference HTML page. +// The page uses a
list where: +// -
contains the paper title link +// -
elements contain author forms and the PDF link +// +// We parse using stdlib strings only — no HTML parser. +func parsePapers(html, conference string, year int, baseURL string) []Paper { + var papers []Paper + + // Split on paper title markers. + // Each paper starts with
+ parts := strings.Split(html, `
`) + for i, part := range parts { + if i == 0 { + continue // header before first paper + } + + // Extract title and abstract URL from the Title inside
+ title, abstractURL := extractTitleAndURL(part, baseURL) + if title == "" { + continue + } + + // Everything up to the next
is this paper's
content. + // The authors are in + authors := extractAuthors(part) + + // PDF link: [pdf] + pdfURL := extractPDFURL(part, baseURL) + + papers = append(papers, Paper{ + Title: title, + Authors: authors, + Conference: conference, + Year: year, + PDFURL: pdfURL, + AbstractURL: abstractURL, + }) + } + return papers +} + +// extractTitleAndURL pulls the title text and href from the first anchor in s. +func extractTitleAndURL(s, baseURL string) (title, url string) { + // Find Title + aStart := strings.Index(s, " and + textStart := strings.Index(rest, ">") + if textStart < 0 { + return "", "" + } + textEnd := strings.Index(rest[textStart:], "") + if textEnd < 0 { + return "", "" + } + title = strings.TrimSpace(rest[textStart+1 : textStart+textEnd]) + title = htmlUnescape(title) + + if strings.HasPrefix(href, "http") { + url = href + } else { + url = baseURL + href + } + return title, url +} + +// extractAuthors collects all query_author hidden input values. +func extractAuthors(s string) []string { + var authors []string + needle := `name="query_author" value="` + rest := s + for { + idx := strings.Index(rest, needle) + if idx < 0 { + break + } + rest = rest[idx+len(needle):] + end := strings.Index(rest, "\"") + if end < 0 { + break + } + name := htmlUnescape(strings.TrimSpace(rest[:end])) + if name != "" { + authors = append(authors, name) + } + rest = rest[end:] + } + return authors +} + +// extractPDFURL finds the [pdf] link in the
block. +func extractPDFURL(s, baseURL string) string { + // Look for >pdf + idx := strings.Index(s, ">pdf") + if idx < 0 { + return "" + } + // Walk back to find href=" + before := s[:idx] + hrefIdx := strings.LastIndex(before, `href="`) + if hrefIdx < 0 { + return "" + } + rest := before[hrefIdx+len(`href="`):] + end := strings.Index(rest, "\"") + if end < 0 { + return "" + } + href := rest[:end] + if strings.HasPrefix(href, "http") { + return href + } + return baseURL + href +} + +// parseConferences extracts Conference records from the /menu page. +// Each entry looks like: +// +// CVPR 2024, Seattle Washington [Main Conference] ... +func parseConferences(html, baseURL string) []Conference { + var confs []Conference + + // Focus on the content div + start := strings.Index(html, `
`) + if start >= 0 { + html = html[start:] + } + + // Each
element is one conference entry + parts := strings.Split(html, "
") + for i, part := range parts { + if i == 0 { + continue + } + // Stop at footer / non-conference content + if strings.Contains(part, `
block into a Conference. +func parseConferenceEntry(s, baseURL string) Conference { + // Text before the first [, e.g. "CVPR 2024, Seattle Washington " + bracketIdx := strings.Index(s, "[") + if bracketIdx < 0 { + return Conference{} + } + header := strings.TrimSpace(s[:bracketIdx]) + // Strip any leading
or tags + if idx := strings.LastIndex(header, ">"); idx >= 0 { + header = strings.TrimSpace(header[idx+1:]) + } + + // Parse "CVPR 2024, Seattle Washington" or "CVPR 2024" + var name, location string + var year int + commaIdx := strings.Index(header, ",") + if commaIdx >= 0 { + location = strings.TrimSpace(header[commaIdx+1:]) + header = header[:commaIdx] + } + + // header is now "CVPR 2024" + fields := strings.Fields(header) + if len(fields) >= 2 { + name = fields[0] + fmt.Sscanf(fields[1], "%d", &year) + } else if len(fields) == 1 { + name = fields[0] + } + + // Extract the Main Conference URL + mcIdx := strings.Index(s, "Main Conference") + if mcIdx < 0 { + return Conference{} + } + hrefBefore := s[:mcIdx] + hrefIdx := strings.LastIndex(hrefBefore, `href="`) + var confURL string + if hrefIdx >= 0 { + rest := hrefBefore[hrefIdx+len(`href="`):] + end := strings.Index(rest, "\"") + if end >= 0 { + href := rest[:end] + if strings.HasPrefix(href, "http") { + confURL = href + } else { + confURL = baseURL + "/" + strings.TrimLeft(href, "/") + } + } + } + + if name == "" { + return Conference{} + } + return Conference{ + Name: name, + Year: year, + Location: location, + URL: confURL, + } +} + +// htmlUnescape replaces common HTML entities with their UTF-8 equivalents. +func htmlUnescape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, """, "\"") + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, " ", " ") + return s +} diff --git a/cvf/cvf_test.go b/cvf/cvf_test.go index 5909bed..b3d4b13 100644 --- a/cvf/cvf_test.go +++ b/cvf/cvf_test.go @@ -1,62 +1,210 @@ -package cvf +package cvf_test import ( "context" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" - "time" + + "github.com/tamnd/cvf-cli/cvf" ) -func TestGet(t *testing.T) { +// paperListHTML returns a minimal CVF conference page with n fake papers. +func paperListHTML(n int) string { + var sb strings.Builder + sb.WriteString(`

Papers

`) + for i := 0; i < n; i++ { + author1 := fmt.Sprintf("Alice Author%d", i) + author2 := fmt.Sprintf("Bob Builder%d", i) + title := fmt.Sprintf("Paper Title Number %d", i) + slug := fmt.Sprintf("Author%d_Paper_Title_CVPR_2024_paper", i) + fmt.Fprintf(&sb, ` +

%s
+
+
+ +%s, +
+
+ +%s +
+
+
+[pdf] +
`, slug, title, i, author1, author1, i, author2, author2, slug) + } + sb.WriteString(`
`) + return sb.String() +} + +// menuHTML returns a minimal /menu page with a few conference entries. +func menuHTML() string { + return ` +
+

CVF Sponsored Conferences

+
+
+CVPR 2024, Seattle Washington [Main Conference]

+
+
+ICCV 2023, Paris France [Main Conference]

+
+
+CVPR 2023, Vancouver Canada [Main Conference]

+
+
+
+` +} + +func newTestServer(t *testing.T) (*httptest.Server, *cvf.Client) { + t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("User-Agent") == "" { t.Error("request carried no User-Agent") } - _, _ = w.Write([]byte("ok")) + switch { + case r.URL.Path == "/menu": + fmt.Fprint(w, menuHTML()) + case strings.HasPrefix(r.URL.Path, "/CVPR2024"): + fmt.Fprint(w, paperListHTML(5)) + default: + http.NotFound(w, r) + } })) - defer srv.Close() + t.Cleanup(srv.Close) + + cfg := cvf.DefaultConfig() + cfg.BaseURL = srv.URL + cfg.Rate = 0 + cfg.Retries = 0 + return srv, cvf.NewClient(cfg) +} - c := NewClient() - c.Rate = 0 // no pacing in the test +func TestPapers(t *testing.T) { + _, client := newTestServer(t) - body, err := c.Get(context.Background(), srv.URL) + papers, err := client.Papers(context.Background(), "CVPR", 2024, 0) if err != nil { - t.Fatal(err) + t.Fatalf("Papers: %v", err) + } + if len(papers) != 5 { + t.Errorf("got %d papers, want 5", len(papers)) + } + + p := papers[0] + if p.Title == "" { + t.Error("paper has empty title") } - if string(body) != "ok" { - t.Errorf("body = %q, want %q", body, "ok") + if p.Conference != "CVPR" { + t.Errorf("conference = %q, want CVPR", p.Conference) + } + if p.Year != 2024 { + t.Errorf("year = %d, want 2024", p.Year) + } + if len(p.Authors) == 0 { + t.Error("paper has no authors") + } + if p.PDFURL == "" { + t.Error("paper has empty PDF URL") + } + if p.AbstractURL == "" { + t.Error("paper has empty abstract URL") } } -func TestGetRetriesOn503(t *testing.T) { - var hits int - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits++ - if hits < 3 { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte("recovered")) - })) - defer srv.Close() +func TestPapersLimit(t *testing.T) { + _, client := newTestServer(t) + + papers, err := client.Papers(context.Background(), "CVPR", 2024, 3) + if err != nil { + t.Fatalf("Papers: %v", err) + } + if len(papers) != 3 { + t.Errorf("got %d papers, want 3", len(papers)) + } +} - c := NewClient() - c.Rate = 0 - c.Retries = 5 +func TestSearch(t *testing.T) { + _, client := newTestServer(t) - start := time.Now() - body, err := c.Get(context.Background(), srv.URL) + // "title number 2" should match "Paper Title Number 2" + results, err := client.Search(context.Background(), "Number 2", "CVPR", 2024, 0) if err != nil { - t.Fatal(err) + t.Fatalf("Search: %v", err) } - if string(body) != "recovered" { - t.Errorf("body = %q after retries", body) + if len(results) != 1 { + t.Errorf("got %d results, want 1", len(results)) + } + if len(results) > 0 && !strings.Contains(results[0].Title, "2") { + t.Errorf("unexpected result title: %q", results[0].Title) + } +} + +func TestSearchByAuthor(t *testing.T) { + _, client := newTestServer(t) + + // "alice author3" is an author of paper 3 + results, err := client.Search(context.Background(), "alice author3", "CVPR", 2024, 0) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 1 { + t.Errorf("got %d results, want 1", len(results)) + } +} + +func TestConferences(t *testing.T) { + _, client := newTestServer(t) + + confs, err := client.Conferences(context.Background()) + if err != nil { + t.Fatalf("Conferences: %v", err) + } + if len(confs) == 0 { + t.Fatal("got 0 conferences") + } + + // first entry should be CVPR 2024 + found := false + for _, c := range confs { + if c.Name == "CVPR" && c.Year == 2024 { + found = true + if c.Location == "" { + t.Error("conference has empty location") + } + if c.URL == "" { + t.Error("conference has empty URL") + } + } + } + if !found { + t.Errorf("CVPR 2024 not in conferences: %+v", confs) + } +} + +func TestPapersNotFound(t *testing.T) { + _, client := newTestServer(t) + + // ECCV9999 will return 404 from our test server + _, err := client.Papers(context.Background(), "ECCV", 9999, 0) + if err == nil { + t.Fatal("expected error for unknown conference, got nil") + } +} + +func TestDefaultConfig(t *testing.T) { + cfg := cvf.DefaultConfig() + if cfg.BaseURL == "" { + t.Error("DefaultConfig has empty BaseURL") } - if hits != 3 { - t.Errorf("server saw %d hits, want 3", hits) + if cfg.UserAgent == "" { + t.Error("DefaultConfig has empty UserAgent") } - if time.Since(start) < 500*time.Millisecond { - t.Error("retries did not back off") + if cfg.Retries <= 0 { + t.Error("DefaultConfig has non-positive Retries") } } diff --git a/go.mod b/go.mod index e48f980..5c312ce 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,34 @@ go 1.26 require ( github.com/charmbracelet/fang v1.0.0 + github.com/mattn/go-isatty v0.0.22 github.com/spf13/cobra v1.10.2 ) + +require ( + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect + github.com/charmbracelet/x/ansi v0.11.0 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.4.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7d4564 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= +github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= +github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/render/render.go b/pkg/render/render.go new file mode 100644 index 0000000..017798f --- /dev/null +++ b/pkg/render/render.go @@ -0,0 +1,350 @@ +// Package render turns slices of record structs into one of the output formats +// cvf-cli supports: table, json, jsonl, csv, tsv, url, and raw. It works +// off struct reflection and json tags, so any record type renders without +// per-type code. +package render + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" + "text/tabwriter" + "text/template" + "time" +) + +// Format is an output rendering format. +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" + FormatJSONL Format = "jsonl" + FormatCSV Format = "csv" + FormatTSV Format = "tsv" + FormatURL Format = "url" + FormatRaw Format = "raw" +) + +// Valid reports whether f is one of the supported formats. +func (f Format) Valid() bool { + switch f { + case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw: + return true + } + return false +} + +// Renderer writes records in a chosen format. +type Renderer struct { + Format Format + Fields []string + NoHeader bool + Template string + w io.Writer +} + +// New builds a Renderer writing to w. +func New(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer { + return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w} +} + +// Render writes records (a slice of structs, or a single struct) in the configured format. +func (r *Renderer) Render(records any) error { + rv := reflect.ValueOf(records) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Slice { + s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1) + s.Index(0).Set(rv) + rv = s + } + n := rv.Len() + items := make([]any, n) + for i := 0; i < n; i++ { + items[i] = rv.Index(i).Interface() + } + + if r.Template != "" { + return r.renderTemplate(items) + } + switch r.Format { + case FormatJSON: + return r.renderJSON(items) + case FormatJSONL: + return r.renderJSONL(items) + case FormatCSV: + return r.renderDelimited(items, ',') + case FormatTSV: + return r.renderDelimited(items, '\t') + case FormatURL: + return r.renderURL(items) + case FormatRaw: + return r.renderRaw(items) + default: + return r.renderTable(items) + } +} + +func (r *Renderer) renderJSON(items []any) error { + enc := json.NewEncoder(r.w) + enc.SetIndent("", " ") + if len(items) == 1 { + return enc.Encode(items[0]) + } + return enc.Encode(items) +} + +func (r *Renderer) renderJSONL(items []any) error { + enc := json.NewEncoder(r.w) + for _, it := range items { + if err := enc.Encode(it); err != nil { + return err + } + } + return nil +} + +func (r *Renderer) renderTemplate(items []any) error { + t, err := template.New("row").Funcs(template.FuncMap{ + "join": func(sep string, v any) string { return joinAny(sep, v) }, + }).Parse(r.Template) + if err != nil { + return fmt.Errorf("parse --template: %w", err) + } + for _, it := range items { + if err := t.Execute(r.w, toAnyMap(it)); err != nil { + return err + } + _, _ = fmt.Fprintln(r.w) + } + return nil +} + +func (r *Renderer) renderURL(items []any) error { + for _, it := range items { + m := toMap(it) + if u := firstNonEmpty(m["url"], m["hn_url"], m["permalink"]); u != "" { + _, _ = fmt.Fprintln(r.w, u) + } + } + return nil +} + +func (r *Renderer) renderRaw(items []any) error { + cols := r.columns(items) + for _, it := range items { + m := toMap(it) + vals := make([]string, 0, len(cols)) + for _, c := range cols { + vals = append(vals, m[c]) + } + _, _ = fmt.Fprintln(r.w, strings.Join(vals, " ")) + } + return nil +} + +func (r *Renderer) renderTable(items []any) error { + if len(items) == 0 { + return nil + } + cols := r.columns(items) + tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0) + if !r.NoHeader { + _, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t")) + } + for _, it := range items { + m := toMap(it) + cells := make([]string, len(cols)) + for i, c := range cols { + cells[i] = truncate(m[c], 60) + } + _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) + } + return tw.Flush() +} + +func (r *Renderer) renderDelimited(items []any, comma rune) error { + if len(items) == 0 { + return nil + } + cols := r.columns(items) + cw := csv.NewWriter(r.w) + cw.Comma = comma + if !r.NoHeader { + if err := cw.Write(cols); err != nil { + return err + } + } + for _, it := range items { + m := toMap(it) + row := make([]string, len(cols)) + for i, c := range cols { + row[i] = m[c] + } + if err := cw.Write(row); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + +func (r *Renderer) columns(items []any) []string { + if len(r.Fields) > 0 { + return r.Fields + } + if len(items) == 0 { + return nil + } + return structJSONKeys(items[0]) +} + +func toAnyMap(v any) any { + data, err := json.Marshal(v) + if err != nil { + return v + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return v + } + return m +} + +func joinAny(sep string, v any) string { + switch vv := v.(type) { + case nil: + return "" + case []string: + return strings.Join(vv, sep) + case []any: + parts := make([]string, len(vv)) + for i, e := range vv { + parts[i] = fmt.Sprintf("%v", e) + } + return strings.Join(parts, sep) + default: + return fmt.Sprintf("%v", v) + } +} + +func toMap(v any) map[string]string { + out := map[string]string{} + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return out + } + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath != "" { + continue + } + key := jsonKey(f) + if key == "-" { + continue + } + out[key] = formatValue(rv.Field(i)) + } + return out +} + +func structJSONKeys(v any) []string { + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return nil + } + rt := rv.Type() + var keys []string + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath != "" { + continue + } + key := jsonKey(f) + if key == "-" { + continue + } + keys = append(keys, key) + } + return keys +} + +func jsonKey(f reflect.StructField) string { + tag := f.Tag.Get("json") + if tag == "" { + return f.Name + } + name := strings.Split(tag, ",")[0] + if name == "" { + return f.Name + } + return name +} + +func formatValue(v reflect.Value) string { + switch v.Kind() { + case reflect.String: + return v.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(v.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'g', -1, 64) + case reflect.Bool: + return strconv.FormatBool(v.Bool()) + case reflect.Slice: + parts := make([]string, v.Len()) + for i := 0; i < v.Len(); i++ { + parts[i] = formatValue(v.Index(i)) + } + return strings.Join(parts, ";") + case reflect.Struct: + if t, ok := v.Interface().(time.Time); ok { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) + } + } + return fmt.Sprintf("%v", v.Interface()) +} + +func upperAll(ss []string) []string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = strings.ToUpper(s) + } + return out +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len([]rune(s)) <= n { + return s + } + rs := []rune(s) + return string(rs[:n-1]) + "..." +}