diff --git a/cli/cmd_list.go b/cli/cmd_list.go new file mode 100644 index 0000000..57ea52d --- /dev/null +++ b/cli/cmd_list.go @@ -0,0 +1,70 @@ +package cli + +import "github.com/spf13/cobra" + +// listCmd is an alias for subjects. +func (a *App) listCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all CS subject guides (alias for subjects)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + subjects, err := a.client.Subjects(cmd.Context()) + if err != nil { + return mapFetchErr(err) + } + if a.limit > 0 && len(subjects) > a.limit { + subjects = subjects[:a.limit] + } + return a.renderOrEmpty(subjects, len(subjects)) + }, + } +} + +// topicCmd fetches one subject by slug. +func (a *App) topicCmd() *cobra.Command { + return &cobra.Command{ + Use: "topic ", + Short: "Show details for a CS topic by slug (e.g. programming)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + subjects, err := a.client.Subjects(cmd.Context()) + if err != nil { + return mapFetchErr(err) + } + for _, s := range subjects { + if s.Slug == args[0] { + return a.render([]*subjectSlice{ + {Rank: s.Rank, Slug: s.Slug, Title: s.Title, URL: s.URL, BookURL: s.BookURL}, + }) + } + } + return codeError(exitNoData, nil) + }, + } +} + +// subjectSlice is render-friendly (same fields as Subject). +type subjectSlice struct { + Rank int `json:"rank" csv:"rank" tsv:"rank"` + Slug string `json:"slug" csv:"slug" tsv:"slug"` + Title string `json:"title" csv:"title" tsv:"title"` + URL string `json:"url" csv:"url" tsv:"url"` + BookURL string `json:"book_url" csv:"book_url" tsv:"book_url"` +} + +// infoCmd prints site stats. +func (a *App) infoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Print site stats (subject count)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + info, err := a.client.SiteInfo(cmd.Context()) + if err != nil { + return mapFetchErr(err) + } + return a.render(info) + }, + } +} diff --git a/cli/root.go b/cli/root.go index f358e4b..7b2f52c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -72,7 +72,10 @@ func Root() *cobra.Command { pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx") root.AddCommand( + app.listCmd(), app.subjectsCmd(), + app.topicCmd(), + app.infoCmd(), newVersionCmd(), ) return root diff --git a/tycs/domain.go b/tycs/domain.go index d378964..675e5da 100644 --- a/tycs/domain.go +++ b/tycs/domain.go @@ -39,17 +39,30 @@ tools. No API key, nothing to run alongside it.`, func (Domain) Register(app *kit.App) { app.SetClient(newClient) - // Resolver op: look up a single subject by slug. Seeds the mint index so - // ant can address subject records as tycs://subject/. + // list: all subjects. + kit.Handle(app, kit.OpMeta{Name: "list", Group: "read", List: true, + Summary: "List all CS subject guides", + URIType: "subject"}, listSubjects) + + // subjects: alias for list. + kit.Handle(app, kit.OpMeta{Name: "subjects", Group: "read", List: true, + Summary: "List all CS subject guides from Teach Yourself CS", + URIType: "subject"}, listSubjects) + + // topic: fetch one subject by slug. + kit.Handle(app, kit.OpMeta{Name: "topic", Group: "read", Single: true, + Summary: "Fetch a CS subject guide by slug", URIType: "subject", + Args: []kit.Arg{{Name: "slug", Help: "subject slug (e.g. programming)"}}}, getSubject) + + // subject: resolver op. kit.Handle(app, kit.OpMeta{Name: "subject", Group: "read", Single: true, Summary: "Fetch a CS subject guide by slug", URIType: "subject", Resolver: true, Args: []kit.Arg{{Name: "slug", Help: "subject slug (e.g. programming)"}}}, getSubject) - // List op: all subjects. - kit.Handle(app, kit.OpMeta{Name: "subjects", Group: "read", List: true, - Summary: "List all CS subject guides from Teach Yourself CS", - URIType: "subject"}, listSubjects) + // info: site stats. + kit.Handle(app, kit.OpMeta{Name: "info", Group: "read", Single: true, + Summary: "Print site stats (subject count)"}, getSiteInfo) } func newClient(_ context.Context, cfg kit.Config) (any, error) { @@ -78,6 +91,10 @@ type subjectsIn struct { Client *Client `kit:"inject"` } +type infoIn struct { + Client *Client `kit:"inject"` +} + func getSubject(ctx context.Context, in subjectRef, emit func(*Subject) error) error { subjects, err := in.Client.Subjects(ctx) if err != nil { @@ -104,6 +121,14 @@ func listSubjects(ctx context.Context, in subjectsIn, emit func(*Subject) error) return nil } +func getSiteInfo(ctx context.Context, in infoIn, emit func(*Info) error) error { + info, err := in.Client.SiteInfo(ctx) + if err != nil { + return err + } + return emit(info) +} + func (Domain) Classify(input string) (uriType, id string, err error) { input = strings.TrimSpace(input) if input == "" { diff --git a/tycs/info_test.go b/tycs/info_test.go new file mode 100644 index 0000000..909c600 --- /dev/null +++ b/tycs/info_test.go @@ -0,0 +1,28 @@ +package tycs_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSiteInfo(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fakeHTML) + })) + defer ts.Close() + + c := newTestClient(ts) + info, err := c.SiteInfo(context.Background()) + if err != nil { + t.Fatal(err) + } + if info.Subjects != 2 { + t.Errorf("Subjects = %d, want 2", info.Subjects) + } + if info.Site == "" { + t.Error("Site should not be empty") + } +} diff --git a/tycs/tycs.go b/tycs/tycs.go index 36725db..09afac8 100644 --- a/tycs/tycs.go +++ b/tycs/tycs.go @@ -96,6 +96,19 @@ func (c *Client) Subjects(ctx context.Context) ([]*Subject, error) { return subjects, nil } +// SiteInfo returns site-level stats. +func (c *Client) SiteInfo(ctx context.Context) (*Info, error) { + subjects, err := c.Subjects(ctx) + if err != nil { + return nil, err + } + return &Info{ + Site: Host, + Subjects: len(subjects), + Source: c.cfg.BaseURL, + }, nil +} + func (c *Client) get(ctx context.Context, url string) ([]byte, error) { var lastErr error for attempt := 0; attempt <= c.cfg.Retries; attempt++ { diff --git a/tycs/types.go b/tycs/types.go index 3a2c529..3b50a40 100644 --- a/tycs/types.go +++ b/tycs/types.go @@ -8,3 +8,10 @@ type Subject struct { URL string `json:"url" csv:"url" tsv:"url"` BookURL string `json:"book_url" csv:"book_url" tsv:"book_url"` } + +// Info is site-level stats. +type Info struct { + Site string `json:"site" csv:"site" tsv:"site"` + Subjects int `json:"subjects" csv:"subjects" tsv:"subjects"` + Source string `json:"source" csv:"source" tsv:"source"` +}