package skiprules import ( "context" "errors" "fmt" "io" "log/slog" "text/tabwriter" "github.com/spf13/cobra" "git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/skiprules" "git.avsdev.uk/Go/job-search/internal/store" "git.avsdev.uk/Go/job-search/internal/types" ) // applyPageSize is how many opportunities apply reads at a time. const applyPageSize = 500 // match is a stored opportunity the rules skip, and the filter that does. type match struct { opportunity types.Opportunity filter string } // commandApply returns the "apply" command, which marks the stored // opportunities that match the rules Skipped. func commandApply(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "apply [file]", Short: "Mark stored opportunities that match skip rules Skipped", Long: `Mark the stored opportunities that match the skip rules Skipped, listing each one. The rules are read from the file given, or else from sources.skip_rules_file. Only the Skipped mark is ever set, and never cleared: opportunities already skipped are left alone. With --dry-run, the matches are listed but nothing is changed. Each change is recorded for clients to catch up from, but isn't pushed to open browsers as a running "serve" pushes its own changes.`, Args: cobra.MaximumNArgs(1), PersistentPreRun: func(cmd *cobra.Command, args []string) { slog.Debug("command persistent pre-run", "command", "skip-rules.apply") }, PersistentPostRun: func(cmd *cobra.Command, args []string) { slog.Debug("command persistent post-run", "command", "skip-rules.apply") }, RunE: func(cmd *cobra.Command, args []string) (err error) { slog.Debug("command run", "command", "skip-rules.apply") dryRun, _ := cmd.Flags().GetBool("dry-run") conf, err := opts.RunSetup(cmd, true, true) if err != nil { return fmt.Errorf("load configuration: %w", err) } rules, _, err := loadRules(conf, args) if err != nil { return err } opportunityStore, err := cli.OpenConfiguredStore(cmd.Context(), conf) if err != nil { return err } defer func() { err = errors.Join(err, opportunityStore.Close()) }() matches, err := findMatches(cmd.Context(), opportunityStore, rules) if err != nil { return err } err = printMatches(cmd.OutOrStdout(), matches) if err != nil { return err } if dryRun { _, err = fmt.Fprintf( cmd.OutOrStdout(), "%d opportunities would be skipped (dry run)\n", len(matches), ) return err } err = skipAll(cmd.Context(), opportunityStore, matches) if err != nil { return err } _, err = fmt.Fprintf( cmd.OutOrStdout(), "Skipped %d opportunities\n", len(matches), ) return err }, } cmd.Flags().Bool("dry-run", false, "list the matches without changing them") return cmd } // findMatches returns the stored opportunities not yet skipped that rules // match, newest first. They are all read before any is changed, so changing // them doesn't move the pages being read. func findMatches( ctx context.Context, opportunityStore *store.Store, rules *skiprules.Rules, ) ([]match, error) { var matches []match for offset := 0; ; offset += applyPageSize { page, err := opportunityStore.ListOpportunities( ctx, store.OpportunityFilter{ Skipped: store.MarkUnset, Limit: applyPageSize, Offset: offset, }, ) if err != nil { return nil, fmt.Errorf("listing opportunities: %w", err) } for i := range page { filter, matched := rules.Match(&page[i]) if matched { matches = append(matches, match{ opportunity: page[i], filter: filter, }) } } if len(page) < applyPageSize { return matches, nil } } } // printMatches lists matches, one per line: UUID, filter and title. func printMatches(out io.Writer, matches []match) error { table := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) for _, found := range matches { _, err := fmt.Fprintf( table, "%s\t%s\t%s\n", found.opportunity.UUID, found.filter, found.opportunity.Title, ) if err != nil { return err } } return table.Flush() } // skipAll sets the Skipped mark of every match, keeping its other marks. func skipAll( ctx context.Context, opportunityStore *store.Store, matches []match, ) error { for _, found := range matches { marks := found.opportunity.Marks marks.Skipped = true if err := opportunityStore.SetMarks(ctx, marks); err != nil { return fmt.Errorf( "skipping %s: %w", found.opportunity.UUID, err, ) } } return nil }