Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cli/cmd_conferences.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions cli/cmd_papers.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions cli/cmd_search.go
Original file line number Diff line number Diff line change
@@ -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 <query>",
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)

Check failure on line 34 in cli/cmd_search.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintf` is not checked (errcheck)

Check failure on line 34 in cli/cmd_search.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
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
}
11 changes: 11 additions & 0 deletions cli/errors.go
Original file line number Diff line number Diff line change
@@ -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)
}
25 changes: 25 additions & 0 deletions cli/output.go
Original file line number Diff line number Diff line change
@@ -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)
}
130 changes: 125 additions & 5 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Loading
Loading