├── .gitignore ├── docs └── hnjobs-0.1.0.gif ├── db ├── management.go ├── schema.sql └── db.go ├── cmd ├── version.go ├── root.go ├── rescore.go ├── dump.go └── fetch.go ├── app ├── rescore.go ├── fetch.go └── browse.go ├── LICENSE ├── go.mod ├── sanitview ├── sanitview_test.go └── sanitview.go ├── config ├── config_test.go └── config.go ├── scoring └── scoring.go ├── theme ├── default.go ├── theme.go ├── material.go └── gruvbox.go ├── hn ├── hn.go └── hn_test.go ├── regionlist ├── regionlistmanager_test.go ├── regionlistmanager.go ├── regionlist_test.go └── regionlist.go ├── README.md ├── main.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | hnjobs 3 | -------------------------------------------------------------------------------- /docs/hnjobs-0.1.0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwinters0/hnjobs/HEAD/docs/hnjobs-0.1.0.gif -------------------------------------------------------------------------------- /db/management.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import _ "embed" 4 | 5 | //go:embed schema.sql 6 | var newDBSchema string 7 | 8 | func NewDB(filepath string) error { 9 | err := OpenDB(filepath) 10 | if err != nil { 11 | return err 12 | } 13 | _, err = store.db.Exec(newDBSchema) 14 | if err != nil { 15 | return err 16 | } 17 | return nil 18 | } 19 | 20 | // TODO schema migrations 21 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var versionCmd = &cobra.Command{ 9 | Use: "version", 10 | Short: "Show app version", 11 | Long: "Show app version", 12 | Run: version, 13 | } 14 | 15 | func init() { 16 | rootCmd.AddCommand(versionCmd) 17 | } 18 | 19 | func version(cmd *cobra.Command, args []string) { 20 | fmt.Println("hnjobs v0.3.2") 21 | } 22 | -------------------------------------------------------------------------------- /app/rescore.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "github.com/mwinters0/hnjobs/db" 6 | "github.com/mwinters0/hnjobs/scoring" 7 | ) 8 | 9 | func ReScore(storyID int) (int, error) { 10 | dbcs, err := db.GetAllJobsByStoryId(storyID, db.OrderNone) 11 | if err != nil { 12 | return 0, errors.New("error finding jobs in the database: " + err.Error()) 13 | } 14 | if len(dbcs) == 0 { 15 | return 0, errors.New("found zero jobs in the database") 16 | } 17 | 18 | numRescored := 0 19 | for _, dbc := range dbcs { 20 | scoring.ScoreDBComment(dbc) 21 | err = db.UpsertJob(dbc) 22 | if err != nil { 23 | return numRescored, errors.New("failed to upsert the db comment: " + err.Error()) 24 | } 25 | numRescored++ 26 | } 27 | 28 | return numRescored, nil 29 | } 30 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "hnjobs" ( 2 | "id" INTEGER NOT NULL UNIQUE, 3 | "parent" INTEGER NOT NULL, 4 | "company" TEXT NOT NULL, 5 | "text" TEXT NOT NULL, 6 | "time" INTEGER NOT NULL, 7 | "fetched_time" INTEGER NOT NULL, 8 | "reviewed_time" INTEGER, 9 | "why" TEXT, 10 | "why_not" TEXT, 11 | "score" INTEGER NOT NULL DEFAULT 0, 12 | "read" INTEGER NOT NULL DEFAULT 0, 13 | "interested" INTEGER NOT NULL DEFAULT 1, 14 | "priority" INTEGER NOT NULL DEFAULT 0, 15 | "applied" INTEGER NOT NULL DEFAULT 0, 16 | PRIMARY KEY("id") 17 | ); 18 | 19 | CREATE TABLE "hnstories" ( 20 | "id" INTEGER NOT NULL UNIQUE, 21 | "kids" TEXT, 22 | "time" INTEGER NOT NULL, 23 | "title" TEXT, 24 | "fetched_time" INTEGER NOT NULL, 25 | PRIMARY KEY("id") 26 | ); -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/mwinters0/hnjobs/app" 5 | "github.com/spf13/cobra" 6 | "os" 7 | ) 8 | 9 | var rootCmd = &cobra.Command{ 10 | Use: "hnjobs", 11 | Short: "Making tomorrow's world a better place today", 12 | Long: `hnjobs: making tomorrow's world a better place today 13 | 14 | Just run the app without any commands / flags unless you think you're special. Press F1 in the TUI for help. 15 | 16 | `, 17 | Run: browse, 18 | CompletionOptions: cobra.CompletionOptions{ 19 | DisableDefaultCmd: true, 20 | }, 21 | } 22 | 23 | func Execute() { 24 | err := rootCmd.Execute() 25 | if err != nil { 26 | os.Exit(1) 27 | } 28 | } 29 | 30 | func init() { 31 | //rootCmd.Flags().BoolVarP(&app.BrowseOptions.MouseEnabled, "mouse", "m", true, "Set TTY mouse enabled (default --mouse=true)") 32 | } 33 | 34 | func browse(cmd *cobra.Command, args []string) { 35 | app.Browse() 36 | return 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Winters 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mwinters0/hnjobs 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/adrg/xdg v0.5.3 7 | github.com/gdamore/tcell/v2 v2.8.1 8 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026 9 | github.com/spf13/cobra v1.9.1 10 | modernc.org/sqlite v1.38.0 11 | ) 12 | 13 | require ( 14 | github.com/dustin/go-humanize v1.0.1 // indirect 15 | github.com/gdamore/encoding v1.0.1 // indirect 16 | github.com/google/uuid v1.6.0 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-isatty v0.0.20 // indirect 20 | github.com/mattn/go-runewidth v0.0.16 // indirect 21 | github.com/ncruces/go-strftime v0.1.9 // indirect 22 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 23 | github.com/rivo/uniseg v0.4.7 // indirect 24 | github.com/spf13/pflag v1.0.6 // indirect 25 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 26 | golang.org/x/sys v0.33.0 // indirect 27 | golang.org/x/term v0.32.0 // indirect 28 | golang.org/x/text v0.26.0 // indirect 29 | modernc.org/libc v1.66.1 // indirect 30 | modernc.org/mathutil v1.7.1 // indirect 31 | modernc.org/memory v1.11.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /cmd/rescore.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/mwinters0/hnjobs/app" 7 | "github.com/mwinters0/hnjobs/db" 8 | "github.com/spf13/cobra" 9 | "log" 10 | ) 11 | 12 | var rescoreCmd = &cobra.Command{ 13 | Use: "rescore", 14 | Short: "Re-score all jobs in the database without fetching (useful if your rules changed)", 15 | Long: `Re-score all jobs in the database without fetching (useful if your rules changed)`, 16 | Run: rescore, 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(rescoreCmd) 21 | } 22 | 23 | func rescore(cmd *cobra.Command, args []string) { 24 | stories, err := db.GetAllStories() 25 | if err != nil && !errors.Is(err, db.ErrNoResults) { 26 | log.Fatal("Error getting stories from DB: " + err.Error()) 27 | } 28 | if errors.Is(err, db.ErrNoResults) { 29 | fmt.Println("No stories found") 30 | return 31 | } 32 | 33 | numRescored := 0 34 | if len(stories) == 1 { 35 | fmt.Printf("Found %d story, rescoring...\n", len(stories)) 36 | } else { 37 | fmt.Printf("Found %d stories, rescoring...\n", len(stories)) 38 | } 39 | for _, story := range stories { 40 | num, err := app.ReScore(story.Id) 41 | if err != nil { 42 | log.Fatal("ERROR: " + err.Error()) 43 | } 44 | numRescored += num 45 | } 46 | fmt.Printf("Rescored %d jobs\n", numRescored) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/dump.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/mwinters0/hnjobs/db" 8 | "github.com/mwinters0/hnjobs/hn" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var dumpCmd = &cobra.Command{ 13 | Use: "dump", 14 | Short: "Dump the current month's data to stdout as json", 15 | Long: "Dump the current month's data to stdout as json", 16 | Run: dump, 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(dumpCmd) 21 | } 22 | 23 | type dumpData struct { 24 | Story *hn.Story 25 | Jobs []*db.Job 26 | } 27 | 28 | func dump(cmd *cobra.Command, args []string) { 29 | latest, err := db.GetLatestStory() 30 | if errors.Is(err, db.ErrNoResults) { 31 | panic("No stories found") 32 | return 33 | } 34 | if err != nil { 35 | panic(fmt.Errorf("error finding latest job story from DB: %v", err)) 36 | } 37 | jobs, err := db.GetAllJobsByStoryId(latest.Id, db.OrderScoreDesc) 38 | if err != nil { 39 | panic(fmt.Sprintf("error getting jobs from DB: %v", err)) 40 | } 41 | if len(jobs) == 0 { 42 | panic(fmt.Sprintf("No jobs in DB for latest story ID %d (%s)", latest.Id, latest.Title)) 43 | } 44 | 45 | d := &dumpData{ 46 | Story: latest, 47 | Jobs: jobs, 48 | } 49 | j, err := json.Marshal(d) 50 | if err != nil { 51 | panic("Error marshaling JSON") 52 | } 53 | fmt.Println(string(j)) 54 | } 55 | -------------------------------------------------------------------------------- /sanitview/sanitview_test.go: -------------------------------------------------------------------------------- 1 | package sanitview 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestStyles(t *testing.T) { 9 | t.Run("StringToStyle", func(t *testing.T) { 10 | s := StringToStyle("[a:b:c:d]") 11 | checkStylesEqual(t, &TViewStyle{ 12 | Fg: "a", 13 | Bg: "b", 14 | Attrs: "c", 15 | Url: "d", 16 | }, s) 17 | s = StringToStyle("[:::d]") 18 | checkStylesEqual(t, &TViewStyle{ 19 | Fg: "", 20 | Bg: "", 21 | Attrs: "", 22 | Url: "d", 23 | }, s) 24 | }) 25 | t.Run("StyleToString", func(t *testing.T) { 26 | s := &TViewStyle{ 27 | Fg: "a", 28 | Bg: "b", 29 | Attrs: "c", 30 | Url: "d", 31 | } 32 | checkStringsEqual(t, "[a:b:c:d]", StyleToString(s)) 33 | s = &TViewStyle{ 34 | Fg: "", 35 | Bg: "", 36 | Attrs: "", 37 | Url: "d", 38 | } 39 | checkStringsEqual(t, "[:::d]", StyleToString(s)) 40 | }) 41 | t.Run("MergeStyles", func(t *testing.T) { 42 | s1 := &TViewStyle{ 43 | Fg: "a", 44 | Bg: "b", 45 | Attrs: "c", 46 | Url: "d", 47 | } 48 | s2 := &TViewStyle{ 49 | Fg: "e", 50 | Bg: "f", 51 | Attrs: "g", 52 | Url: "h", 53 | } 54 | checkStylesEqual(t, s2, MergeTviewStyles(s1, s2)) 55 | s2 = &TViewStyle{ 56 | Fg: "", 57 | Bg: "f", 58 | Attrs: "g", 59 | Url: "", 60 | } 61 | checkStylesEqual(t, &TViewStyle{ 62 | Fg: "a", 63 | Bg: "f", 64 | Attrs: "g", 65 | Url: "d", 66 | }, MergeTviewStyles(s1, s2)) 67 | }) 68 | 69 | // TODO AsTCellStyle 70 | } 71 | 72 | func checkStylesEqual(t *testing.T, e *TViewStyle, a *TViewStyle) { 73 | if !reflect.DeepEqual(a, e) { 74 | t.Errorf("Styles not equal\n expected:%#v\n actual:%#v\n", e, a) 75 | } 76 | } 77 | 78 | func checkStringsEqual(t *testing.T, e string, a string) { 79 | if a != e { 80 | t.Errorf("Strings not equal\n expected:\"%s\"\n actual:\"%s\"\n", e, a) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/mwinters0/hnjobs/sanitview" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestDefaultContents(t *testing.T) { 10 | expected := ConfigObj{ 11 | Version: 1, 12 | Cache: CacheConfig{ 13 | TTLSecs: 86400, 14 | }, 15 | Scoring: ScoringConfig{ 16 | Rules: []ScoringRule{ 17 | { 18 | TextFound: "(?i)sre", 19 | Score: 1, 20 | TagsWhy: []string{"career"}, 21 | }, 22 | { 23 | TextFound: "(?i)reliability", 24 | Score: 1, 25 | TagsWhy: []string{"career"}, 26 | }, 27 | { 28 | TextFound: "(?i)resiliency", 29 | Score: 1, 30 | TagsWhy: []string{"career"}, 31 | }, 32 | { 33 | TextFound: "(?i)principal", 34 | Score: 1, 35 | TagsWhy: []string{"level"}, 36 | }, 37 | { 38 | TextFound: "(?i)staff\\b", 39 | Score: 1, 40 | TagsWhy: []string{"level"}, 41 | }, 42 | { 43 | TextFound: "(?i)aws", 44 | Score: 1, 45 | TagsWhy: []string{"tech"}, 46 | }, 47 | { 48 | TextFound: "(?i)\\brust\\b", 49 | Score: 1, 50 | TagsWhy: []string{"tech", "memecred"}, 51 | Style: &sanitview.TViewStyle{Fg: "deeppink"}, 52 | }, 53 | { 54 | TextFound: "(?i)golang", 55 | Score: 1, 56 | TagsWhy: []string{"tech"}, 57 | }, 58 | { 59 | TextFound: "(?i)\\bgo\\b", 60 | Score: 1, 61 | TagsWhy: []string{"tech"}, 62 | }, 63 | { 64 | TextFound: "(?i)open.?source", 65 | Score: 2, 66 | TagsWhy: []string{"values"}, 67 | }, 68 | { 69 | TextFound: "(?i)education", 70 | Score: 2, 71 | TagsWhy: []string{"values"}, 72 | }, 73 | { 74 | TextFound: "(?i)\\bheal", 75 | Score: 2, 76 | TagsWhy: []string{"values"}, 77 | }, 78 | { 79 | TextMissing: "(?i)remote", 80 | Score: -100, 81 | TagsWhyNot: []string{"onsite", "fsckbezos"}, 82 | }, 83 | }, 84 | }, 85 | Display: DisplayConfig{ 86 | ScoreThreshold: 1, 87 | Theme: "default", 88 | }, 89 | } 90 | 91 | err := loadConfigJSON(DefaultConfigFileContents()) 92 | if err != nil { 93 | t.Error(err) 94 | } 95 | if !reflect.DeepEqual(expected, config) { 96 | t.Errorf( 97 | "Config contents are not the same.\n Expected:\n%v\n Got:\n%v", 98 | expected, config, 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /cmd/fetch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mwinters0/hnjobs/app" 7 | "github.com/mwinters0/hnjobs/config" 8 | "github.com/spf13/cobra" 9 | "log" 10 | "os" 11 | ) 12 | 13 | // fetchCmd represents the fetch command 14 | var fetchCmd = &cobra.Command{ 15 | Use: "fetch", 16 | Short: "Fetch without loading the UI", 17 | Long: `Fetch and score the latest job story and comments without loading the UI.`, 18 | Run: fetch, 19 | } 20 | 21 | var flagForce bool 22 | var flagQuiet bool 23 | var flagStoryID int 24 | var flagExit bool 25 | 26 | func init() { 27 | rootCmd.AddCommand(fetchCmd) 28 | fetchCmd.Flags().BoolVarP( 29 | &flagForce, 30 | "force", "f", 31 | false, 32 | "Fetch and score all comments, ignoring cache TTL", 33 | ) 34 | fetchCmd.Flags().BoolVarP( 35 | &flagQuiet, 36 | "quiet", "q", 37 | false, 38 | "Don't print status info. Might still print errors.", 39 | ) 40 | fetchCmd.Flags().IntVarP( 41 | &flagStoryID, 42 | "storyid", "s", 43 | 0, 44 | "Fetch a specific story ID instead of latest", 45 | ) 46 | fetchCmd.Flags().BoolVarP( 47 | &flagExit, 48 | "exit", "x", 49 | false, 50 | "Exit code is 0 only if new jobs were fetched. Empty success is code 42.", 51 | ) 52 | } 53 | 54 | func fetch(cmd *cobra.Command, args []string) { 55 | status := make(chan app.FetchStatusUpdate) 56 | fo := app.FetchOptions{ 57 | Context: context.Background(), 58 | Status: status, 59 | ModeForce: flagForce, 60 | StoryID: flagStoryID, 61 | TTLSec: config.GetConfig().Cache.TTLSecs, 62 | MustContain: app.WhoIsHiringString, 63 | } 64 | go app.FetchAsync(fo) 65 | for { 66 | select { 67 | case fsu, ok := <-status: 68 | if !ok { 69 | //EOF - it's a bug if we see this 70 | log.Fatal("BUG: cmd/fetch: status channel closed before UpdateTypeDone!") 71 | } 72 | if !flagQuiet { 73 | fmt.Println(fsu.Message) 74 | } 75 | switch fsu.UpdateType { 76 | case app.UpdateTypeFatal: 77 | if !flagQuiet { 78 | fmt.Println("Fetch experienced fatal errors.") 79 | } 80 | os.Exit(1) 81 | case app.UpdateTypeGeneric, 82 | app.UpdateTypeNewStory, 83 | app.UpdateTypeNonFatalErr, 84 | app.UpdateTypeBadComment, 85 | app.UpdateTypeJobFetched: 86 | case app.UpdateTypeDone: 87 | // This is where we intend to exit 88 | if flagExit && fsu.Value == 0 { 89 | // no new jobs fetched 90 | os.Exit(42) 91 | } 92 | return 93 | default: 94 | log.Fatal(fmt.Sprintf("BUG: cmd/fetch: unhandled UpdateType %d", fsu.UpdateType)) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /scoring/scoring.go: -------------------------------------------------------------------------------- 1 | package scoring 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mwinters0/hnjobs/config" 6 | "github.com/mwinters0/hnjobs/db" 7 | "regexp" 8 | "slices" 9 | ) 10 | 11 | type RuleType int 12 | 13 | const ( 14 | TextFound RuleType = iota 15 | TextMissing 16 | ) 17 | 18 | func (rt RuleType) String() string { 19 | switch rt { 20 | case TextFound: 21 | return "TextFound" 22 | case TextMissing: 23 | return "TextMissing" 24 | default: 25 | panic(fmt.Errorf("unhandled rule type %d", rt)) 26 | } 27 | } 28 | 29 | type Rule struct { 30 | config.ScoringRule 31 | RuleType RuleType 32 | Regex *regexp.Regexp 33 | } 34 | 35 | func newRuleFromConf(confRule *config.ScoringRule) (*Rule, error) { 36 | var rt RuleType 37 | if confRule.TextFound != "" { 38 | rt = TextFound 39 | } else if confRule.TextMissing != "" { 40 | rt = TextMissing 41 | } 42 | r := &Rule{ 43 | *confRule, 44 | rt, 45 | nil, 46 | } 47 | switch rt { 48 | case TextFound: 49 | r.Regex = regexp.MustCompile(confRule.TextFound) 50 | case TextMissing: 51 | r.Regex = regexp.MustCompile(confRule.TextMissing) 52 | default: 53 | return nil, fmt.Errorf("unhandled rule type %d", rt) 54 | } 55 | return r, nil 56 | } 57 | 58 | var rules []*Rule 59 | 60 | func ReloadRules() error { 61 | // create Rule list from config 62 | confRules := config.GetConfig().Scoring.Rules 63 | rules = make([]*Rule, len(confRules)) 64 | for i, confRule := range confRules { 65 | r, err := newRuleFromConf(&confRule) 66 | if err != nil { 67 | return err 68 | } 69 | rules[i] = r 70 | } 71 | return nil 72 | } 73 | 74 | func GetRules() []*Rule { 75 | if len(rules) == 0 { 76 | err := ReloadRules() 77 | if err != nil { 78 | panic(err) 79 | } 80 | } 81 | return rules 82 | } 83 | 84 | func ScoreDBComment(dbc *db.Job) int { 85 | if len(rules) == 0 { 86 | err := ReloadRules() 87 | if err != nil { 88 | panic(err) 89 | } 90 | } 91 | dbc.Score = 0 92 | for _, r := range rules { 93 | applyRule(r, dbc) 94 | } 95 | return dbc.Score 96 | } 97 | 98 | func applyRule(rule *Rule, dbc *db.Job) { 99 | shouldMatch := true //is this a regular Rule (Regex should return true) or an inverse Rule (should return false)? 100 | if rule.RuleType == TextMissing { 101 | shouldMatch = false 102 | } 103 | matched := rule.Regex.MatchString(dbc.Text) 104 | if matched == shouldMatch { 105 | //Rule applies 106 | dbc.Score = dbc.Score + rule.Score 107 | for _, y := range rule.TagsWhy { 108 | if !slices.Contains(dbc.Why, y) { 109 | dbc.Why = append(dbc.Why, y) 110 | } 111 | } 112 | for _, n := range rule.TagsWhyNot { 113 | if !slices.Contains(dbc.WhyNot, n) { 114 | dbc.WhyNot = append(dbc.WhyNot, n) 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /theme/default.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import "github.com/mwinters0/hnjobs/sanitview" 4 | 5 | // only used to populate default-theme.json 6 | func getDefaultTheme() Theme { 7 | white := "#f9f5d7" 8 | black := "#1d2021" 9 | hnOrange := "#cc5200" 10 | normal := &sanitview.TViewStyle{Fg: white, Bg: black} 11 | companyNameFg := "deepskyblue" 12 | return Theme{ 13 | Version: 1, 14 | UI: UIColors{ 15 | HeaderStatsDate: &sanitview.TViewStyle{Fg: white, Bg: hnOrange}, 16 | HeaderStatsNormal: &sanitview.TViewStyle{Fg: black, Bg: "orange", Attrs: "b"}, 17 | HeaderStatsHidden: &sanitview.TViewStyle{Fg: "#BBBBBB", Bg: "#333333"}, 18 | FocusBorder: &sanitview.TViewStyle{Fg: white}, 19 | ModalTitle: &sanitview.TViewStyle{Fg: black, Bg: "orange"}, 20 | ModalNormal: normal, 21 | ModalHighlight: &sanitview.TViewStyle{Fg: "orange", Bg: black}, 22 | }, 23 | CompanyList: CompanyList{ 24 | Colors: CompanyListColors{ 25 | CompanyName: &sanitview.TViewStyle{Fg: companyNameFg, Bg: black}, 26 | CompanyNameUnread: &sanitview.TViewStyle{Attrs: "b"}, 27 | CompanyNamePriority: &sanitview.TViewStyle{Fg: black, Bg: "orange"}, 28 | CompanyNameApplied: &sanitview.TViewStyle{}, 29 | CompanyNameUninterested: &sanitview.TViewStyle{Fg: "grey"}, 30 | StatusChar: &sanitview.TViewStyle{Fg: white, Bg: black}, 31 | StatusCharUnread: &sanitview.TViewStyle{Fg: "purple", Attrs: "b"}, 32 | StatusCharPriority: &sanitview.TViewStyle{Fg: black, Bg: "orange"}, 33 | StatusCharApplied: &sanitview.TViewStyle{Fg: "green"}, 34 | StatusCharUninterested: &sanitview.TViewStyle{Fg: "grey"}, 35 | Score: &sanitview.TViewStyle{Fg: white, Bg: black}, 36 | ScoreUnread: &sanitview.TViewStyle{Attrs: "b"}, 37 | ScorePriority: &sanitview.TViewStyle{Fg: "black", Bg: "orange"}, 38 | ScoreApplied: &sanitview.TViewStyle{}, 39 | ScoreUninterested: &sanitview.TViewStyle{Fg: "grey"}, 40 | SelectedItemBackground: &sanitview.TViewStyle{Bg: "#444444"}, 41 | FrameBackground: &sanitview.TViewStyle{Bg: black}, 42 | FrameHeader: &sanitview.TViewStyle{Fg: white, Bg: hnOrange}, 43 | }, 44 | Chars: CompanyListChars{ 45 | Read: " ", 46 | Unread: "*", 47 | Applied: "🗹", 48 | Uninterested: "⨯", 49 | Priority: "★", 50 | }, 51 | }, 52 | JobBody: JobBodyColors{ 53 | Normal: normal, 54 | CompanyName: &sanitview.TViewStyle{Fg: companyNameFg}, 55 | URL: &sanitview.TViewStyle{Fg: "#c69749", Attrs: "u"}, 56 | Email: &sanitview.TViewStyle{Fg: "pink"}, 57 | PositiveHit: &sanitview.TViewStyle{Fg: "#a1f6ae"}, 58 | NegativeHit: &sanitview.TViewStyle{Fg: "red"}, 59 | Pre: &sanitview.TViewStyle{Fg: "#bbbbbb"}, 60 | FrameBackground: &sanitview.TViewStyle{Bg: black}, 61 | FrameHeader: &sanitview.TViewStyle{Fg: white, Bg: hnOrange}, 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /hn/hn.go: -------------------------------------------------------------------------------- 1 | package hn 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | var baseURL = "https://hacker-news.firebaseio.com/v0" // to facilitate testing 14 | 15 | func fetch(ctx context.Context, url string) ([]byte, error) { 16 | req, err := http.NewRequestWithContext(ctx, "GET", baseURL+url, nil) 17 | if err != nil { 18 | return nil, err 19 | } 20 | client := http.DefaultClient 21 | resp, err := client.Do(req) 22 | defer func() { 23 | if resp != nil { 24 | resp.Body.Close() 25 | } 26 | }() 27 | if err != nil { 28 | return nil, err 29 | } 30 | var bodyBytes []byte 31 | bodyBytes, err = io.ReadAll(resp.Body) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return bodyBytes, nil 36 | } 37 | 38 | // TODO: Everything in the HN API is an "item". We should fetch items and validate type before converting to Comment / etc. 39 | 40 | // https://hacker-news.firebaseio.com/v0/user/whoishiring/submitted.json 41 | func FetchSubmissions(ctx context.Context, user string) ([]int, error) { 42 | resp, err := fetch(ctx, "/user/"+user+"/submitted.json") 43 | if err != nil { 44 | return nil, fmt.Errorf("can't fetch: %v", err) 45 | } 46 | 47 | var i []int 48 | err = json.Unmarshal(resp, &i) 49 | if err != nil { 50 | return nil, fmt.Errorf("can't unmarshal: %v", err) 51 | } 52 | return i, nil 53 | } 54 | 55 | // https://hacker-news.firebaseio.com/v0/item/41709301.json 56 | type Story struct { 57 | Id int 58 | Kids []int 59 | Time int64 60 | GoTime time.Time `json:"-"` 61 | Title string 62 | FetchedTime int64 `json:"-"` 63 | FetchedGoTime time.Time `json:"-"` 64 | } 65 | 66 | func FetchStory(ctx context.Context, id int) (*Story, error) { 67 | var s Story 68 | resp, err := fetch(ctx, "/item/"+strconv.Itoa(id)+".json") 69 | if err != nil { 70 | return &s, fmt.Errorf("can't fetch: %v", err) 71 | } 72 | err = json.Unmarshal(resp, &s) 73 | if err != nil { 74 | return &s, fmt.Errorf("can't unmarshal: %v", err) 75 | } 76 | s.FetchedGoTime = time.Now() 77 | s.FetchedTime = s.FetchedGoTime.Unix() 78 | s.GoTime = time.Unix(s.Time, 0) 79 | return &s, nil 80 | } 81 | 82 | // https://hacker-news.firebaseio.com/v0/item/41733646.json 83 | type Comment struct { 84 | Id int 85 | Parent int 86 | Text string 87 | Time int64 88 | GoTime time.Time `json:"-"` 89 | FetchedTime int64 `json:"-"` 90 | FetchedGoTime time.Time `json:"-"` 91 | } 92 | 93 | func FetchComment(ctx context.Context, id int) (*Comment, error) { 94 | var c Comment 95 | resp, err := fetch(ctx, "/item/"+strconv.Itoa(id)+".json") 96 | if err != nil { 97 | return nil, fmt.Errorf("can't fetch: %v", err) 98 | } 99 | err = json.Unmarshal(resp, &c) 100 | if err != nil { 101 | return nil, fmt.Errorf("can't unmarshal: %v", err) 102 | } 103 | c.GoTime = time.Unix(c.Time, 0) 104 | now := time.Now() 105 | c.FetchedTime = now.Unix() 106 | c.FetchedGoTime = now 107 | return &c, nil 108 | } 109 | -------------------------------------------------------------------------------- /regionlist/regionlistmanager_test.go: -------------------------------------------------------------------------------- 1 | package regionlist 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestRLMRegions(t *testing.T) { 9 | t.Run("Insert", func(t *testing.T) { 10 | // 1: 11 | // aaabbbccc 12 | // 2: 13 | // ddeeeeeff 14 | 15 | rlm := NewRegionListManager(9) 16 | err := rlm.CreateRegionList(0, "a") 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | _ = rlm.InsertRegion(0, &Region{ 21 | Start: 3, 22 | End: 5, 23 | Value: "b", 24 | }) 25 | _ = rlm.InsertRegion(0, &Region{ 26 | Start: 6, 27 | End: 8, 28 | Value: "c", 29 | }) 30 | expected0 := &RegionList{ 31 | len: 9, 32 | regions: []*Region{ 33 | { 34 | Start: 0, 35 | End: 2, 36 | Value: "a", 37 | }, 38 | { 39 | Start: 3, 40 | End: 5, 41 | Value: "b", 42 | }, 43 | { 44 | Start: 6, 45 | End: 8, 46 | Value: "c", 47 | }, 48 | }, 49 | } 50 | compareRegionLists(t, expected0, rlm.regionLists[0]) 51 | 52 | err = rlm.CreateRegionList(1, "d") 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | _ = rlm.InsertRegion(1, &Region{ 57 | Start: 2, 58 | End: 6, 59 | Value: "e", 60 | }) 61 | _ = rlm.InsertRegion(1, &Region{ 62 | Start: 7, 63 | End: 8, 64 | Value: "f", 65 | }) 66 | expected1 := &RegionList{ 67 | len: 9, 68 | regions: []*Region{ 69 | { 70 | Start: 0, 71 | End: 1, 72 | Value: "d", 73 | }, 74 | { 75 | Start: 2, 76 | End: 6, 77 | Value: "e", 78 | }, 79 | { 80 | Start: 7, 81 | End: 8, 82 | Value: "f", 83 | }, 84 | }, 85 | } 86 | compareRegionLists(t, expected1, rlm.regionLists[1]) 87 | }) 88 | 89 | // TODO test Add 90 | } 91 | 92 | func TestRLMMergedEvents(t *testing.T) { 93 | // 1: 94 | // aaabbbccc 95 | // 2: 96 | // ddeeeeeff 97 | rlm := NewRegionListManager(9) 98 | err := rlm.CreateRegionList(0, "a") 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | _ = rlm.InsertRegion(0, &Region{ 103 | Start: 3, 104 | End: 5, 105 | Value: "b", 106 | }) 107 | _ = rlm.InsertRegion(0, &Region{ 108 | Start: 6, 109 | End: 8, 110 | Value: "c", 111 | }) 112 | err = rlm.CreateRegionList(1, "d") 113 | if err != nil { 114 | t.Error(err) 115 | } 116 | _ = rlm.InsertRegion(1, &Region{ 117 | Start: 2, 118 | End: 6, 119 | Value: "e", 120 | }) 121 | _ = rlm.InsertRegion(1, &Region{ 122 | Start: 7, 123 | End: 8, 124 | Value: "f", 125 | }) 126 | 127 | var emitted []MergedEvent 128 | for m := range rlm.MergedEvents() { 129 | emitted = append(emitted, m) 130 | } 131 | //for _, e := range emitted { 132 | // fmt.Printf("%#v\n", e) 133 | //} 134 | 135 | expected := []MergedEvent{ 136 | {Offset: 0, Values: map[int]string{0: "a", 1: "d"}}, 137 | {Offset: 2, Values: map[int]string{0: "a", 1: "e"}}, 138 | {Offset: 3, Values: map[int]string{0: "b", 1: "e"}}, 139 | {Offset: 6, Values: map[int]string{0: "c", 1: "e"}}, 140 | {Offset: 7, Values: map[int]string{0: "c", 1: "f"}}, 141 | } 142 | 143 | if !reflect.DeepEqual(emitted, expected) { 144 | t.Errorf("expected:\n%#v\nactual:\n%#v", expected, emitted) 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /hn/hn_test.go: -------------------------------------------------------------------------------- 1 | package hn 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestFetch(t *testing.T) { 13 | t.Run("Submissions", func(t *testing.T) { 14 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | expectedURL := "/user/foouser/submitted.json" 16 | if r.URL.Path != expectedURL { 17 | t.Errorf("Expected to request '%s', got: %s", expectedURL, r.URL.Path) 18 | } 19 | w.WriteHeader(http.StatusOK) 20 | _, _ = w.Write([]byte(`[1,2,5]`)) 21 | })) 22 | defer server.Close() 23 | 24 | baseURL = server.URL 25 | actual, err := FetchSubmissions(context.Background(), "foouser") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | expected := []int{1, 2, 5} 30 | if !reflect.DeepEqual(actual, expected) { 31 | t.Errorf("Expected:\n %v\ngot:\n %v", expected, actual) 32 | } 33 | }) 34 | 35 | t.Run("Story", func(t *testing.T) { 36 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | expectedURL := "/item/42.json" 38 | if r.URL.Path != expectedURL { 39 | t.Errorf("Expected to request '%s', got: %s", expectedURL, r.URL.Path) 40 | } 41 | w.WriteHeader(http.StatusOK) 42 | _, _ = w.Write([]byte(`{ 43 | "by": "whoishiring", 44 | "descendants": 3, 45 | "id": 42, 46 | "kids": [55, 56, 57], 47 | "score": 999, 48 | "text": "boilerplate", 49 | "time": 1727794816, 50 | "title": "Ask HN: Who is hiring? (October 2024)", 51 | "type": "story" 52 | }`)) 53 | })) 54 | defer server.Close() 55 | 56 | baseURL = server.URL 57 | actual, err := FetchStory(context.Background(), 42) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | expected := &Story{ 62 | Id: 42, 63 | Kids: []int{55, 56, 57}, 64 | Time: 1727794816, 65 | GoTime: time.Unix(1727794816, 0), 66 | Title: "Ask HN: Who is hiring? (October 2024)", 67 | FetchedTime: actual.FetchedTime, // no good way to test these 68 | FetchedGoTime: actual.FetchedGoTime, 69 | } 70 | if !reflect.DeepEqual(actual, expected) { 71 | t.Errorf("Expected:\n %v\ngot:\n %v", expected, actual) 72 | } 73 | }) 74 | 75 | t.Run("Comment", func(t *testing.T) { 76 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 | expectedURL := "/item/77.json" 78 | if r.URL.Path != expectedURL { 79 | t.Errorf("Expected to request '%s', got: %s", expectedURL, r.URL.Path) 80 | } 81 | w.WriteHeader(http.StatusOK) 82 | _, _ = w.Write([]byte(`{ 83 | "by": "bro", 84 | "id": 77, 85 | "parent": 999, 86 | "text": "goodjob", 87 | "time": 1727794816, 88 | "type": "comment" 89 | }`)) 90 | })) 91 | defer server.Close() 92 | 93 | baseURL = server.URL 94 | actual, err := FetchComment(context.Background(), 77) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | expected := &Comment{ 99 | Id: 77, 100 | Parent: 999, 101 | Text: "goodjob", 102 | Time: 1727794816, 103 | GoTime: time.Unix(1727794816, 0), 104 | FetchedTime: actual.FetchedTime, // no good way to test these 105 | FetchedGoTime: actual.FetchedGoTime, 106 | } 107 | if !reflect.DeepEqual(actual, expected) { 108 | t.Errorf("Expected:\n %#v\ngot:\n %#v", expected, actual) 109 | } 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /sanitview/sanitview.go: -------------------------------------------------------------------------------- 1 | // Package sanitview provides foundational capabilities for managing tview styles. It enables them to be built 2 | // programmatically, and creates functionality similar to cascading stylesheets where one style can override a subset 3 | // of another. 4 | package sanitview 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "github.com/gdamore/tcell/v2" 10 | "strings" 11 | ) 12 | 13 | type TViewStringStyle = string 14 | type TViewStyle struct { 15 | Fg string 16 | Bg string 17 | Attrs string 18 | Url string 19 | } 20 | 21 | func (tvs *TViewStyle) AsTag() string { 22 | return StyleToString(tvs) 23 | } 24 | 25 | func (tvs *TViewStyle) AsTCellStyle() tcell.Style { 26 | s := tcell.Style{} 27 | if tvs.Fg != "" { 28 | s = s.Foreground(tcell.GetColor(tvs.Fg)) 29 | } 30 | if tvs.Bg != "" { 31 | s = s.Background(tcell.GetColor(tvs.Bg)) 32 | } 33 | if tvs.Attrs != "" { 34 | a := strings.ToLower(tvs.Attrs) 35 | var m tcell.AttrMask 36 | if strings.Contains(a, "l") { 37 | m ^= tcell.AttrBlink 38 | } 39 | if strings.Contains(a, "b") { 40 | m ^= tcell.AttrBold 41 | } 42 | if strings.Contains(a, "i") { 43 | m ^= tcell.AttrItalic 44 | } 45 | if strings.Contains(a, "d") { 46 | m ^= tcell.AttrDim 47 | } 48 | if strings.Contains(a, "r") { 49 | m ^= tcell.AttrReverse 50 | } 51 | if strings.Contains(a, "u") { 52 | m ^= tcell.AttrUnderline 53 | } 54 | if strings.Contains(a, "s") { 55 | m ^= tcell.AttrStrikeThrough 56 | } 57 | s = s.Attributes(m) 58 | } 59 | return s 60 | } 61 | 62 | func (tvs *TViewStyle) Clone() *TViewStyle { 63 | return &TViewStyle{ 64 | Fg: tvs.Fg, 65 | Bg: tvs.Bg, 66 | Attrs: tvs.Attrs, 67 | Url: tvs.Url, 68 | } 69 | } 70 | 71 | func (s *TViewStyle) IsEmpty() bool { 72 | return s.Fg == "" && s.Bg == "" && s.Attrs == "" && s.Url == "" 73 | } 74 | 75 | func MergeTviewStyles(styles ...*TViewStyle) *TViewStyle { 76 | if len(styles) < 2 { 77 | panic("styles must have at least two elements") 78 | } 79 | style := TViewStyle{} 80 | for i, newStyle := range styles { 81 | if i == 0 { 82 | style = *newStyle 83 | continue 84 | } 85 | if newStyle.Fg != "" { 86 | style.Fg = newStyle.Fg 87 | } 88 | if newStyle.Bg != "" { 89 | style.Bg = newStyle.Bg 90 | } 91 | if newStyle.Attrs != "" { 92 | style.Attrs = newStyle.Attrs 93 | } 94 | if newStyle.Url != "" { 95 | style.Url = newStyle.Url 96 | } 97 | } 98 | return &style 99 | } 100 | 101 | func StringToStyle(s TViewStringStyle) *TViewStyle { 102 | style := &TViewStyle{} 103 | if s[0:1] != "[" { 104 | panic(fmt.Errorf("not a tview string style: '%s'", s)) 105 | } 106 | if s[len(s)-1:] != "]" { 107 | panic(fmt.Errorf("not a tview string style: '%s'", s)) 108 | } 109 | if !strings.ContainsAny(s, ":") { 110 | // Fg only 111 | style.Fg = s[1 : len(s)-1] 112 | return style 113 | } 114 | i := strings.IndexRune(s, ':') 115 | style.Fg = s[1:i] 116 | s = s[i+1:] 117 | 118 | i = strings.IndexRune(s, ':') 119 | if i == -1 { 120 | style.Bg = s[:len(s)-1] 121 | return style 122 | } 123 | style.Bg = s[:i] 124 | s = s[i+1:] 125 | 126 | i = strings.IndexRune(s, ':') 127 | if i == -1 { 128 | style.Attrs = s[:len(s)-1] 129 | return style 130 | } 131 | style.Attrs = s[:i] 132 | s = s[i+1:] 133 | 134 | if len(s) > 1 { 135 | //nothing left but Url 136 | style.Url = s[0 : len(s)-1] 137 | return style 138 | } 139 | 140 | panic(errors.New("couldn't find end of tview string")) 141 | } 142 | 143 | func StyleToString(style *TViewStyle) TViewStringStyle { 144 | if style.IsEmpty() { 145 | return "" 146 | } 147 | s := "[" + strings.Join([]string{style.Fg, style.Bg, style.Attrs, style.Url}, ":") + "]" 148 | return s 149 | } 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report](https://goreportcard.com/badge/github.com/mwinters0/hnjobs)](https://goreportcard.com/report/github.com/mwinters0/hnjobs) 2 | 3 | # hnjobs 4 | A console tool to find your best match on Who's Hiring. Exports to JSON for automated ennui. 5 | 6 | ![Screenshot](./docs/hnjobs-0.1.0.gif) 7 | 8 | It: 9 | 1. Finds the latest Who's Hiring post and fetches / caches all job comments locally in sqlite. 10 | 2. Scores job postings according to your criteria. 11 | 3. Provides a TUI to help you review the jobs and track which ones are interesting / applied to / ruled out. 12 | 13 | ## Installation 14 | ```shell 15 | go install github.com/mwinters0/hnjobs@latest 16 | ``` 17 | 18 | Or grab a binary from the [releases](https://github.com/mwinters0/hnjobs/releases) 19 | 20 | ## Usage 21 | On first run, a config file is created at `UserConfigDir/hnjobs/config.json`. (On linux this is 22 | `~/.config/hnjobs/config.json`.) 23 | 24 | 👉 **You should edit the config file**👈 before running any other commands, as this is 25 | where your scoring rules are stored. Some samples rules are provided in the generated file. Each rule is a 26 | [golang regex](https://pkg.go.dev/regexp/syntax) which must be JSON escaped (`\b` -> `\\b`). 27 | 28 | After you've set up your rules, run `hnjobs` again and it will auto-fetch the most-recent job story, score the jobs by 29 | your criteria, and show the TUI. 30 | 31 | ### TUI bindings 32 | - Basics 33 | - `ESC` - close dialogs 34 | - `TAB` - switch focus (so you can scroll a long job listing if needed) 35 | - `jk` and up/down arrows - scroll 36 | - `g`, `G`, `Ctrl-d`, `Ctrl-u` - scroll harder 37 | - `f` - fetch latest (only fetches new / TTL expired jobs, with default TTL of 1 day) 38 | - `F` - force fetch all jobs (ignore TTL) 39 | - `q` - quit 40 | - Job Filtering 41 | - `r` - mark read / unread 42 | - `x` - mark job uninterested (hidden) or interested (default) 43 | - `p` - mark priority / not priority 44 | - `a` - mark applied to / not applied to 45 | - `s` - reload config file and re-score the jobs (useful if you've changed your rules) 46 | - Display 47 | - `X` - toggle hiding of jobs marked uninterested 48 | - `T` - toggle hiding of jobs below your score threshold (set in the config file) 49 | - `m` - select month (if multiple in your DB) / delete old months 50 | 51 | ### Commands 52 | ```shell 53 | hnjobs # Works offline. 54 | hnjobs fetch # Just fetch, no TUI. Run this before hopping on a plane. 55 | hnjobs fetch -x # Fetch and set exit code according to results. 0 = new jobs available. 56 | hnjobs rescore # Re-score the cached jobs. Only needed if you've changed your rules. 57 | hnjobs dump # Dump the current month's data to JSON on stdout. 58 | ``` 59 | 60 | ## Scoring rules FAQ 61 | - `text_missing` rules match if the regex fails. Use this to influence the score if a word is missing from a listing. 62 | - `why` and `why_not` tags are optional. I like to analyze my past decisions whenever I watch my credit score drop. 63 | 🤷 These will become visible in the TUI eventually. 64 | - `colorize` is an optional boolean that defaults to `true`. Set to `false` if you don't want this rule to be colorized in the display. 65 | 66 | ## Styling 67 | If you hate orange, you can edit your config file to use one of the built-in themes: `material` or 68 | `gruvbox[dark|light]` (example: `gruvboxdarkhard`). 69 | 70 | If you want to take a crack at making your own, have a look at the default theme which is generated on first-run at 71 | `UserConfigDir/hnjobs/theme-default.json` (on linux: `~/.config/hnjobs/theme-default.json`). You can either edit this 72 | or copy it to `theme-foo.json` and set your config's theme to `foo`. 73 | 74 | ## Misc 75 | The database is stored at `UserDataDir/hnjobs/hnjobs.sqlite` (on linux: `~/.local/share/hnjobs/hnjobs.sqlite`). 76 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/adrg/xdg" 7 | "github.com/mwinters0/hnjobs/cmd" 8 | "github.com/mwinters0/hnjobs/config" 9 | "github.com/mwinters0/hnjobs/db" 10 | "github.com/mwinters0/hnjobs/theme" 11 | "log" 12 | "os" 13 | "strings" 14 | ) 15 | 16 | var reset = "\033[0m" 17 | var fgBlue = "\033[38;5;6;48;5;0m" 18 | var fgYellow = "\033[38;5;11;48;5;0m" 19 | 20 | func main() { 21 | configPath, err := config.GetPath() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | // TODO cleanup: move these to their respective homes, make all of firstRun less ugly 26 | themePath, err := xdg.ConfigFile("hnjobs/") //no filename because theme loading code needs to determine it 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | dbPath, err := xdg.DataFile("hnjobs/hnjobs.sqlite") 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | if checkFirstRun(configPath, themePath+"/theme-default.json", dbPath) { 35 | printPath := configPath 36 | if strings.ContainsAny(printPath, " \t\r\n") { 37 | printPath = "\"" + printPath + "\"" 38 | } 39 | fmt.Printf("\n"+ 40 | fgYellow+"Edit the config file to set up your scoring rules and then run me again:\n"+ 41 | fgBlue+"%s"+reset+"\n", printPath) 42 | return 43 | } 44 | 45 | err = config.Reload() 46 | if err != nil { 47 | log.Fatal("Error loading the config file: " + err.Error()) 48 | } 49 | err = theme.LoadTheme(config.GetConfig().Display.Theme, themePath) 50 | if err != nil { 51 | log.Fatal("Error loading the theme file: " + err.Error()) 52 | } 53 | err = db.OpenDB(dbPath) 54 | if err != nil { 55 | log.Fatal("Error opening the database: " + err.Error()) 56 | } 57 | 58 | cmd.Execute() 59 | } 60 | 61 | func checkFirstRun(configPath string, themePath string, dbPath string) bool { 62 | _, configError := os.Stat(configPath) 63 | if configError != nil && !errors.Is(configError, os.ErrNotExist) { 64 | log.Fatal(fmt.Sprintf("Unable to stat config file: %s", configError)) 65 | } 66 | _, themeError := os.Stat(themePath) 67 | if themeError != nil && !errors.Is(themeError, os.ErrNotExist) { 68 | log.Fatal(fmt.Sprintf("unable to stat theme file: %s", themeError)) 69 | } 70 | _, dbError := os.Stat(dbPath) 71 | if dbError != nil && !errors.Is(dbError, os.ErrNotExist) { 72 | log.Fatal(fmt.Sprintf("unable to stat db file: %s", dbError)) 73 | } 74 | 75 | if errors.Is(configError, os.ErrNotExist) || 76 | errors.Is(themeError, os.ErrNotExist) || 77 | errors.Is(dbError, os.ErrNotExist) { 78 | fmt.Printf("First run detected!\n") 79 | } else { 80 | return false 81 | } 82 | 83 | // Config 84 | if errors.Is(configError, os.ErrNotExist) { 85 | fmt.Printf(" - Creating config file \""+fgBlue+"%s"+reset+"\" ...", configPath) 86 | err := os.WriteFile(configPath, config.DefaultConfigFileContents(), 0644) 87 | if err != nil { 88 | log.Fatal(fmt.Sprintf("Error creating config file: %v", err)) 89 | } 90 | fmt.Printf(" Done.\n") 91 | } 92 | 93 | // Theme 94 | if errors.Is(themeError, os.ErrNotExist) { 95 | fmt.Printf(" - Creating theme \""+fgBlue+"%s"+reset+"\" ...", themePath) 96 | err := os.WriteFile(themePath, theme.DefaultThemeFileContents(), 0644) 97 | if err != nil { 98 | log.Fatal(fmt.Sprintf("Error creating theme file: %v", err)) 99 | } 100 | fmt.Printf(" Done.\n") 101 | } 102 | 103 | // DB 104 | if errors.Is(dbError, os.ErrNotExist) { 105 | fmt.Printf(" - Creating database \""+fgBlue+"%s"+reset+"\" ...", dbPath) 106 | file, err := os.Create(dbPath) 107 | if err != nil { 108 | log.Fatal(fmt.Sprintf("Error creating database file: %v", err)) 109 | } 110 | err = file.Chmod(0644) 111 | if err != nil { 112 | log.Fatal(fmt.Sprintf("Error setting database mode: %v", err)) 113 | } 114 | err = db.NewDB(dbPath) 115 | if err != nil { 116 | log.Fatal(fmt.Sprintf("Error populating new DB: %v", err)) 117 | } 118 | fmt.Printf(" Done.\n") 119 | } 120 | 121 | return true 122 | } 123 | -------------------------------------------------------------------------------- /theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/mwinters0/hnjobs/sanitview" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | var curTheme Theme 13 | 14 | type Theme struct { 15 | Version int 16 | UI UIColors 17 | CompanyList CompanyList 18 | JobBody JobBodyColors 19 | } 20 | 21 | type CompanyList struct { 22 | Chars CompanyListChars 23 | Colors CompanyListColors 24 | } 25 | 26 | type CompanyListChars struct { 27 | Unread string 28 | Read string 29 | Applied string 30 | Uninterested string 31 | Priority string 32 | } 33 | 34 | type CompanyListColors struct { 35 | CompanyName *sanitview.TViewStyle 36 | CompanyNameUnread *sanitview.TViewStyle 37 | CompanyNamePriority *sanitview.TViewStyle 38 | CompanyNameApplied *sanitview.TViewStyle 39 | CompanyNameUninterested *sanitview.TViewStyle 40 | StatusChar *sanitview.TViewStyle 41 | StatusCharUnread *sanitview.TViewStyle 42 | StatusCharPriority *sanitview.TViewStyle 43 | StatusCharApplied *sanitview.TViewStyle 44 | StatusCharUninterested *sanitview.TViewStyle 45 | Score *sanitview.TViewStyle 46 | ScoreUnread *sanitview.TViewStyle 47 | ScorePriority *sanitview.TViewStyle 48 | ScoreApplied *sanitview.TViewStyle 49 | ScoreUninterested *sanitview.TViewStyle 50 | SelectedItemBackground *sanitview.TViewStyle 51 | FrameBackground *sanitview.TViewStyle 52 | FrameHeader *sanitview.TViewStyle 53 | } 54 | 55 | type JobBodyColors struct { 56 | Normal *sanitview.TViewStyle 57 | CompanyName *sanitview.TViewStyle 58 | URL *sanitview.TViewStyle 59 | Email *sanitview.TViewStyle 60 | PositiveHit *sanitview.TViewStyle 61 | NegativeHit *sanitview.TViewStyle 62 | Pre *sanitview.TViewStyle 63 | FrameBackground *sanitview.TViewStyle 64 | FrameHeader *sanitview.TViewStyle 65 | } 66 | 67 | type UIColors struct { 68 | HeaderStatsDate *sanitview.TViewStyle 69 | HeaderStatsNormal *sanitview.TViewStyle 70 | HeaderStatsHidden *sanitview.TViewStyle 71 | FocusBorder *sanitview.TViewStyle 72 | ModalTitle *sanitview.TViewStyle 73 | ModalNormal *sanitview.TViewStyle 74 | ModalHighlight *sanitview.TViewStyle 75 | } 76 | 77 | // GetTheme returns the currently-loaded theme 78 | func GetTheme() Theme { 79 | return curTheme 80 | } 81 | 82 | func LoadTheme(name string, path string) error { 83 | if name == "" { 84 | name = "default" 85 | } 86 | name = strings.Replace(name, "/", "", -1) //bunny foo foo 87 | switch strings.ToLower(name) { 88 | case "material": 89 | curTheme = getMaterialTheme() 90 | case "gruvbox", "gruvboxdark": 91 | curTheme = getGruvboxTheme(GruvboxModeDark, GruvboxIntensityNeutral) 92 | case "gruvboxdarkhard": 93 | curTheme = getGruvboxTheme(GruvboxModeDark, GruvboxIntensityHard) 94 | case "gruvboxdarksoft": 95 | curTheme = getGruvboxTheme(GruvboxModeDark, GruvboxIntensitySoft) 96 | case "gruvboxlight": 97 | curTheme = getGruvboxTheme(GruvboxModeLight, GruvboxIntensityNeutral) 98 | case "gruvboxlighthard": 99 | curTheme = getGruvboxTheme(GruvboxModeLight, GruvboxIntensityHard) 100 | case "gruvboxlightsoft": 101 | curTheme = getGruvboxTheme(GruvboxModeLight, GruvboxIntensitySoft) 102 | default: 103 | fullPath := filepath.Join(path, "theme-"+name+".json") 104 | err := loadThemeFile(fullPath) 105 | if err != nil { 106 | panic(err) 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | func loadThemeFile(filename string) error { 113 | contents, err := os.ReadFile(filename) 114 | if err != nil { 115 | return fmt.Errorf("error reading file \"%s\": %v", filename, err) 116 | } 117 | err = loadThemeJSON(contents) 118 | if err != nil { 119 | return fmt.Errorf("error loading file \"%s\": %v", filename, err) 120 | } 121 | return nil 122 | } 123 | 124 | func loadThemeJSON(j []byte) error { 125 | curTheme = Theme{} 126 | err := json.Unmarshal(j, &curTheme) 127 | if err != nil { 128 | return err 129 | } 130 | // TODO validation ... 131 | return nil 132 | } 133 | 134 | func DefaultThemeFileContents() []byte { 135 | j, err := json.MarshalIndent(getDefaultTheme(), "", " ") 136 | if err != nil { 137 | panic(err) 138 | } 139 | return j 140 | } 141 | -------------------------------------------------------------------------------- /theme/material.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import "github.com/mwinters0/hnjobs/sanitview" 4 | 5 | func getMaterialTheme() Theme { 6 | // converted from https://github.com/MartinSeeler/iterm2-material-design/blob/master/material-design-colors.itermcolors 7 | var material = struct { 8 | ansi0 string // bluegrey 9 | ansi1 string // red 10 | ansi2 string // brightgreen 11 | ansi3 string // brightyellow 12 | ansi4 string // brightblue 13 | ansi5 string // magenta 14 | ansi6 string // brightmint 15 | ansi7 string // almostwhite 16 | ansi8 string // midgrey 17 | ansi9 string // brightsalmon 18 | ansi10 string // mint 19 | ansi11 string // yellow 20 | ansi12 string // lightblue 21 | ansi13 string // salmon 22 | ansi14 string // brightmint 23 | ansi15 string // almost white 24 | background string // almost black 25 | bold string // paper 26 | foreground string // brightpaper 27 | selection string // union 28 | }{ 29 | ansi0: "#435a66", 30 | ansi1: "#fb3841", 31 | ansi2: "#5cf09e", 32 | ansi3: "#fed032", 33 | ansi4: "#36b6fe", 34 | ansi5: "#fb216e", 35 | ansi6: "#58ffd1", 36 | ansi7: "#fffefe", 37 | ansi8: "#a0b0b8", 38 | ansi9: "#fc736d", 39 | ansi10: "#acf6be", 40 | ansi11: "#fee16c", 41 | ansi12: "#6fcefe", 42 | ansi13: "#fc669a", 43 | ansi14: "#99ffe5", 44 | ansi15: "#fffefe", 45 | background: "#1c252a", 46 | bold: "#e9e9e9", 47 | foreground: "#e7eaed", 48 | selection: "#4e6978", 49 | } 50 | 51 | white := material.foreground 52 | black := material.background 53 | brandStrong := material.ansi6 54 | companyNameFg := material.ansi4 55 | 56 | normal := &sanitview.TViewStyle{Fg: white, Bg: black} 57 | dim := &sanitview.TViewStyle{Fg: material.ansi8} 58 | priority := &sanitview.TViewStyle{Fg: material.background, Bg: material.ansi14} 59 | frameHeader := &sanitview.TViewStyle{Fg: white, Bg: material.selection} 60 | 61 | return Theme{ 62 | Version: 1, 63 | UI: UIColors{ 64 | HeaderStatsDate: &sanitview.TViewStyle{Fg: material.background, Bg: material.ansi10}, 65 | HeaderStatsNormal: &sanitview.TViewStyle{Fg: material.background, Bg: material.ansi12}, 66 | HeaderStatsHidden: &sanitview.TViewStyle{Fg: material.ansi8, Bg: material.ansi0}, 67 | FocusBorder: &sanitview.TViewStyle{Fg: brandStrong}, 68 | ModalTitle: &sanitview.TViewStyle{Fg: material.ansi11, Bg: material.selection}, 69 | ModalNormal: normal, 70 | ModalHighlight: &sanitview.TViewStyle{Fg: brandStrong, Bg: black}, 71 | }, 72 | CompanyList: CompanyList{ 73 | Colors: CompanyListColors{ 74 | CompanyName: &sanitview.TViewStyle{Fg: companyNameFg, Bg: black}, 75 | CompanyNameUnread: &sanitview.TViewStyle{Attrs: "b"}, 76 | CompanyNamePriority: priority, 77 | CompanyNameApplied: &sanitview.TViewStyle{}, 78 | CompanyNameUninterested: dim, 79 | StatusChar: normal, 80 | StatusCharUnread: &sanitview.TViewStyle{Fg: material.ansi13, Attrs: "b"}, 81 | StatusCharPriority: priority, 82 | StatusCharApplied: &sanitview.TViewStyle{Fg: material.ansi10}, 83 | StatusCharUninterested: dim, 84 | Score: normal, 85 | ScoreUnread: &sanitview.TViewStyle{Attrs: "b"}, 86 | ScorePriority: priority, 87 | ScoreApplied: &sanitview.TViewStyle{}, 88 | ScoreUninterested: dim, 89 | SelectedItemBackground: &sanitview.TViewStyle{Bg: material.selection}, 90 | FrameBackground: &sanitview.TViewStyle{Bg: black}, 91 | FrameHeader: frameHeader, 92 | }, 93 | Chars: CompanyListChars{ 94 | Read: " ", 95 | Unread: "*", 96 | Applied: "🗹", 97 | Uninterested: "⨯", 98 | Priority: "★", 99 | }, 100 | }, 101 | JobBody: JobBodyColors{ 102 | Normal: normal, 103 | CompanyName: &sanitview.TViewStyle{Fg: companyNameFg}, 104 | URL: &sanitview.TViewStyle{Fg: material.ansi10, Attrs: "u"}, 105 | Email: &sanitview.TViewStyle{Fg: material.ansi14}, 106 | PositiveHit: &sanitview.TViewStyle{Fg: material.ansi6}, 107 | NegativeHit: &sanitview.TViewStyle{Fg: material.ansi9}, 108 | Pre: &sanitview.TViewStyle{Fg: material.ansi8}, 109 | FrameBackground: &sanitview.TViewStyle{Bg: black}, 110 | FrameHeader: frameHeader, 111 | }, 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /regionlist/regionlistmanager.go: -------------------------------------------------------------------------------- 1 | package regionlist 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "iter" 7 | "maps" 8 | "math" 9 | ) 10 | 11 | // RegionListManager allows you to track multiple parallel values over time, represented by RegionList s. 12 | // We use this to track all formatting attributes (fg, bg, underline, etc) across the range of a document. 13 | type RegionListManager struct { 14 | len int 15 | regionLists map[int]*RegionList 16 | emitManagerStart bool 17 | emitListStart bool 18 | emitRegionStart bool 19 | emitRegionEnd bool 20 | emitListEnd bool 21 | emitManagerEnd bool 22 | } 23 | 24 | func NewRegionListManager(len int) *RegionListManager { 25 | return &RegionListManager{ 26 | len: len, 27 | regionLists: map[int]*RegionList{}, 28 | } 29 | } 30 | 31 | func (rlm *RegionListManager) Len() int { 32 | return rlm.len 33 | } 34 | 35 | func (rlm *RegionListManager) Keys() []int { 36 | var k []int 37 | for key := range rlm.regionLists { 38 | k = append(k, key) 39 | } 40 | return k 41 | } 42 | 43 | func (rlm *RegionListManager) AddRegionList(key int, rl *RegionList) error { 44 | if rl.len != rlm.len { 45 | return errors.New(fmt.Sprintf( 46 | "new regionlist has len %d which does not match the manager len %d", rl.len, rlm.len, 47 | )) 48 | } 49 | rlm.regionLists[key] = rl 50 | return nil 51 | } 52 | 53 | func (rlm *RegionListManager) CreateRegionList(key int, defaultValue string) error { 54 | rlm.regionLists[key] = NewRegionList(rlm.len, defaultValue) 55 | return nil 56 | } 57 | 58 | func (rlm *RegionListManager) InsertRegion(key int, region *Region) error { 59 | return rlm.regionLists[key].InsertRegion(region) 60 | } 61 | 62 | type MergedEvent struct { 63 | Offset int 64 | Values map[int]string 65 | } 66 | 67 | func (rlm *RegionListManager) MergedEvents() iter.Seq[MergedEvent] { 68 | rlLoc := make(map[int]int) // currently-considered region in each RL 69 | eligibleKeys := make(map[int]struct{}) 70 | // init 71 | for key := range rlm.regionLists { 72 | // every RL starts at the 0th region 73 | rlLoc[key] = 0 74 | // every key is eligible 75 | eligibleKeys[key] = struct{}{} 76 | } 77 | return func(yield func(MergedEvent) bool) { 78 | // We only need to emit starts because that's when values change, but we can have multiple 79 | // regions with new starts ("winners") at the same offset. 80 | curValues := make(map[int]string) 81 | for { 82 | if len(eligibleKeys) == 0 { 83 | // done! we've emitted all RegionStarts 84 | break 85 | } 86 | var winningOffset = math.MaxInt 87 | var winningKeys []int 88 | for key := range eligibleKeys { 89 | thisOffset := rlm.regionLists[key].regions[rlLoc[key]].Start 90 | if thisOffset <= winningOffset { 91 | // a winner 92 | if thisOffset < winningOffset { 93 | //with better offset than previously seen - eject previous winners 94 | winningKeys = []int{} 95 | } 96 | winningOffset = thisOffset 97 | winningKeys = append(winningKeys, key) 98 | } 99 | } 100 | for _, winningKey := range winningKeys { 101 | curValues[winningKey] = rlm.regionLists[winningKey].regions[rlLoc[winningKey]].Value 102 | rlLoc[winningKey]++ // advance 103 | if rlLoc[winningKey] == len(rlm.regionLists[winningKey].regions) { 104 | // we just emitted the last region for this RL 105 | delete(eligibleKeys, winningKey) 106 | } 107 | } 108 | if !yield(MergedEvent{ 109 | Offset: winningOffset, 110 | Values: maps.Clone(curValues), 111 | }) { 112 | return 113 | } 114 | } 115 | } 116 | } 117 | 118 | func (rlm *RegionListManager) ResizeAt(offset int, amount int) (newLen int, e error) { 119 | if amount < 0 && int(math.Abs(float64(amount))) > rlm.len { 120 | return rlm.len, errors.New(fmt.Sprintf("amount to shrink (%d) is greater than rlm len (%d)", amount, rlm.len)) 121 | } 122 | if amount == 0 { 123 | return rlm.len, nil 124 | } 125 | if offset < 0 { 126 | return rlm.len, errors.New(fmt.Sprintf("index %d is less than 0", offset)) 127 | } 128 | if offset > rlm.len { 129 | return rlm.len, errors.New(fmt.Sprintf("index %d is greater than rlm len %d", offset, rlm.len)) 130 | } 131 | 132 | newLen = rlm.len + amount 133 | for key := range rlm.regionLists { 134 | nl, err := rlm.regionLists[key].ResizeAt(offset, amount) 135 | if err != nil { 136 | return rlm.len, err 137 | } 138 | if nl != newLen { 139 | return rlm.len, errors.New(fmt.Sprintf( 140 | "rl %d return newLen %d, doesn't match rlm newLen %d", 141 | key, nl, newLen, 142 | )) 143 | } 144 | } 145 | rlm.len = newLen 146 | return rlm.len, nil 147 | } 148 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/adrg/xdg" 8 | "github.com/mwinters0/hnjobs/sanitview" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | var config ConfigObj 14 | var configLoaded bool 15 | 16 | type ConfigObj struct { 17 | Version int 18 | Cache CacheConfig `json:"cache"` 19 | Scoring ScoringConfig `json:"scoring"` 20 | Display DisplayConfig `json:"display"` 21 | } 22 | 23 | type CacheConfig struct { 24 | TTLSecs int64 `json:"ttl_secs"` 25 | } 26 | 27 | type ScoringConfig struct { 28 | Rules []ScoringRule `json:"rules"` 29 | } 30 | 31 | type DisplayConfig struct { 32 | ScoreThreshold int `json:"score_threshold"` 33 | Theme string `json:"theme"` 34 | } 35 | 36 | type ScoringRule struct { 37 | TextFound string `json:"text_found,omitempty"` 38 | TextMissing string `json:"text_missing,omitempty"` 39 | Score int `json:"score"` 40 | TagsWhy []string `json:"tags_why,omitempty"` 41 | TagsWhyNot []string `json:"tags_why_not,omitempty"` 42 | Colorize *bool `json:"colorize,omitempty"` // pointer for default nil instead of false 43 | Style *sanitview.TViewStyle `json:"style,omitempty"` 44 | } 45 | 46 | func GetConfig() ConfigObj { 47 | if !configLoaded { 48 | err := Reload() 49 | if err != nil { 50 | panic(err) 51 | } 52 | } 53 | return config 54 | } 55 | 56 | func GetPath() (string, error) { 57 | // ~/.config/ 58 | return xdg.ConfigFile("hnjobs/config.json") //creates path if needed 59 | } 60 | 61 | func Reload() error { 62 | configPath, err := GetPath() 63 | if err != nil { 64 | return err 65 | } 66 | return loadConfigFile(configPath) 67 | } 68 | 69 | func loadConfigFile(filename string) error { 70 | contents, err := os.ReadFile(filename) 71 | if err != nil { 72 | return fmt.Errorf("error reading config file \"%s\": %v", filename, err) 73 | } 74 | err = loadConfigJSON(contents) 75 | if err != nil { 76 | return fmt.Errorf("error loading config file \"%s\": %v", filename, err) 77 | } 78 | return nil 79 | } 80 | 81 | func loadConfigJSON(j []byte) error { 82 | config = ConfigObj{} 83 | err := json.Unmarshal(j, &config) 84 | if err != nil { 85 | return err 86 | } 87 | // validate 88 | for i, r := range config.Scoring.Rules { 89 | if r.TextFound == "" && r.TextMissing == "" { 90 | return errors.New("scoring rules must have either `text_found` or `text_missing`") 91 | } 92 | if r.TextFound != "" && r.TextMissing != "" { 93 | return errors.New("scoring rules cannot have both `text_found` and `text_missing`") 94 | } 95 | if r.TextFound != "" { 96 | config.Scoring.Rules[i].TextFound = r.TextFound 97 | } 98 | if r.TextMissing != "" { 99 | config.Scoring.Rules[i].TextMissing = r.TextMissing 100 | } 101 | } 102 | // TODO validate that tags are sane (json-compliant, sqlite-compliant, no spaces) 103 | 104 | configLoaded = true 105 | return nil 106 | } 107 | 108 | func DefaultConfigFileContents() []byte { 109 | // I want the generated rules to be pleasant to edit, which means one rule per line. 110 | // There's no way to get the standard json package to marshal this way. 111 | 112 | // Don't forget to update the tests 113 | rules := []ScoringRule{ 114 | { 115 | TextFound: "(?i)sre", 116 | Score: 1, 117 | TagsWhy: []string{"career"}, 118 | }, 119 | { 120 | TextFound: "(?i)reliability", 121 | Score: 1, 122 | TagsWhy: []string{"career"}, 123 | }, 124 | { 125 | TextFound: "(?i)resiliency", 126 | Score: 1, 127 | TagsWhy: []string{"career"}, 128 | }, 129 | { 130 | TextFound: "(?i)principal", 131 | Score: 1, 132 | TagsWhy: []string{"level"}, 133 | }, 134 | { 135 | TextFound: "(?i)staff\\b", 136 | Score: 1, 137 | TagsWhy: []string{"level"}, 138 | }, 139 | { 140 | TextFound: "(?i)aws", 141 | Score: 1, 142 | TagsWhy: []string{"tech"}, 143 | }, 144 | { 145 | TextFound: "(?i)\\brust\\b", 146 | Score: 1, 147 | TagsWhy: []string{"tech", "memecred"}, 148 | Style: &sanitview.TViewStyle{Fg: "deeppink"}, 149 | }, 150 | { 151 | TextFound: "(?i)golang", 152 | Score: 1, 153 | TagsWhy: []string{"tech"}, 154 | }, 155 | { 156 | TextFound: "(?i)\\bgo\\b", 157 | Score: 1, 158 | TagsWhy: []string{"tech"}, 159 | }, 160 | { 161 | TextFound: "(?i)open.?source", 162 | Score: 2, 163 | TagsWhy: []string{"values"}, 164 | }, 165 | { 166 | TextFound: "(?i)education", 167 | Score: 2, 168 | TagsWhy: []string{"values"}, 169 | }, 170 | { 171 | TextFound: "(?i)\\bheal", 172 | Score: 2, 173 | TagsWhy: []string{"values"}, 174 | }, 175 | { 176 | TextMissing: "(?i)remote", 177 | Score: -100, 178 | TagsWhyNot: []string{"onsite", "fsckbezos"}, 179 | }, 180 | } 181 | 182 | escapeString := func(s string) string { 183 | // see json/encode.go:appendString() 184 | s = strings.ReplaceAll(s, "\\b", "\\\\b") 185 | s = strings.ReplaceAll(s, "\\f", "\\\\f") 186 | s = strings.ReplaceAll(s, "\\n", "\\\\n") 187 | s = strings.ReplaceAll(s, "\\r", "\\\\r") 188 | s = strings.ReplaceAll(s, "\\t", "\\\\t") 189 | return s 190 | } 191 | 192 | prettyMarshalRule := func(sr *ScoringRule) ([]byte, error) { 193 | var elems []string 194 | if !(sr.TextFound == "") { 195 | elems = append(elems, fmt.Sprintf( 196 | `"text_found": "%s"`, 197 | escapeString(sr.TextFound), 198 | )) 199 | } 200 | if !(sr.TextMissing == "") { 201 | elems = append(elems, fmt.Sprintf( 202 | `"text_missing": "%s"`, 203 | escapeString(sr.TextMissing), 204 | )) 205 | } 206 | elems = append(elems, fmt.Sprintf(`"score": %d`, sr.Score)) 207 | if len(sr.TagsWhy) > 0 { 208 | var quoted []string 209 | for _, tag := range sr.TagsWhy { 210 | quoted = append(quoted, fmt.Sprintf(`"%s"`, tag)) 211 | } 212 | elems = append(elems, fmt.Sprintf( 213 | `"tags_why": [%s]`, 214 | escapeString(strings.Join(quoted, ", ")), 215 | )) 216 | } 217 | if len(sr.TagsWhyNot) > 0 { 218 | var quoted []string 219 | for _, tag := range sr.TagsWhyNot { 220 | quoted = append(quoted, fmt.Sprintf(`"%s"`, tag)) 221 | } 222 | elems = append(elems, fmt.Sprintf( 223 | `"tags_why_not": [%s]`, 224 | escapeString(strings.Join(quoted, ", ")), 225 | )) 226 | } 227 | if sr.Style != nil { 228 | var styleElems []string 229 | if sr.Style.Fg != "" { 230 | styleElems = append(styleElems, fmt.Sprintf(`"Fg": "%s"`, sr.Style.Fg)) 231 | } 232 | if sr.Style.Bg != "" { 233 | styleElems = append(styleElems, fmt.Sprintf(`"Bg": "%s"`, sr.Style.Bg)) 234 | } 235 | if sr.Style.Attrs != "" { 236 | styleElems = append(styleElems, fmt.Sprintf(`"Attrs": "%s"`, sr.Style.Attrs)) 237 | } 238 | if sr.Style.Url != "" { 239 | styleElems = append(styleElems, fmt.Sprintf(`"Url": "%s"`, sr.Style.Url)) 240 | } 241 | elems = append(elems, fmt.Sprintf(`"style": {%s}`, strings.Join(styleElems, ", "))) 242 | } 243 | return []byte(fmt.Sprintf(`{%s}`, strings.Join(elems, ", "))), nil 244 | } 245 | 246 | var renderedRules []string 247 | for _, r := range rules { 248 | j, err := prettyMarshalRule(&r) 249 | if err != nil { 250 | panic(err) 251 | } 252 | renderedRules = append(renderedRules, " "+string(j)) 253 | } 254 | rulesOut := strings.Join(renderedRules, ",\n") 255 | out := fmt.Sprintf(`{ 256 | "version": 1, 257 | "cache": { 258 | "ttl_secs": 86400 259 | }, 260 | "scoring": { 261 | "rules": [ 262 | %s 263 | ] 264 | }, 265 | "display": { 266 | "theme": "default", 267 | "score_threshold": 1 268 | } 269 | } 270 | `, rulesOut) 271 | 272 | return []byte(out) 273 | } 274 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "time" 10 | 11 | //_ "github.com/ncruces/go-sqlite3/driver" //sqlite3 12 | //_ "github.com/ncruces/go-sqlite3/embed" 13 | "github.com/mwinters0/hnjobs/hn" 14 | _ "modernc.org/sqlite" //sqlite 15 | "strconv" 16 | "sync" 17 | ) 18 | 19 | var store = sqlStore{} 20 | 21 | type sqlStore struct { 22 | db *sql.DB 23 | jobUpsert *sql.Stmt 24 | writeMutex sync.Mutex //needed? I was trying different sqlite drivers and not sure all were threadsafe 25 | } 26 | 27 | // scannableRow is either a sql.Row or sql.Rows to facilitate unmarshalling, because those two are not related??? 28 | type scannableRow interface { 29 | Scan(dest ...interface{}) error 30 | } 31 | 32 | var ErrNoResults = errors.New("no results") // hide the sql package 33 | 34 | type JobOrder int 35 | 36 | const ( 37 | OrderNone JobOrder = iota 38 | OrderScoreDesc 39 | OrderScoreAsc 40 | OrderTimeNewestFirst 41 | OrderTimeOldestFirst 42 | ) 43 | 44 | func OpenDB(filePath string) error { 45 | var err error 46 | store.db, err = sql.Open("sqlite", "file:"+filePath) 47 | if err != nil { 48 | return fmt.Errorf("error opening DB: %v", err) 49 | } 50 | return nil 51 | } 52 | 53 | // === table: stories 54 | 55 | func GetStoryById(id int) (*hn.Story, error) { 56 | storyRow := store.db.QueryRow(storySelect+"WHERE Id = ?", strconv.Itoa(id)) 57 | story, err := unmarshalStoryRow(storyRow) 58 | if errors.Is(err, sql.ErrNoRows) { 59 | return story, ErrNoResults 60 | } 61 | if err != nil { 62 | return nil, fmt.Errorf("error retrieving story: %v", err) 63 | } 64 | return story, nil 65 | } 66 | 67 | func GetAllStories() ([]*hn.Story, error) { 68 | stories := []*hn.Story{} 69 | rows, err := store.db.Query(storySelect + "ORDER BY id DESC") 70 | if err != nil { 71 | return nil, fmt.Errorf("error retrieving stories: %v", err) 72 | } 73 | for rows.Next() { 74 | story, err := unmarshalStoryRow(rows) 75 | if errors.Is(err, sql.ErrNoRows) { 76 | return stories, ErrNoResults 77 | } else if err != nil { 78 | return stories, fmt.Errorf("couldn't unmarshal story: %v", err) 79 | } 80 | stories = append(stories, story) 81 | } 82 | return stories, nil 83 | } 84 | 85 | func GetLatestStory() (*hn.Story, error) { 86 | storyRow := store.db.QueryRow(storySelect + "ORDER BY id DESC LIMIT 1") 87 | story, err := unmarshalStoryRow(storyRow) 88 | if errors.Is(err, sql.ErrNoRows) { 89 | return story, ErrNoResults 90 | } 91 | if err != nil { 92 | return nil, fmt.Errorf("error retrieving latest story: %v", err) 93 | } 94 | return story, nil 95 | } 96 | 97 | func DeleteStoryAndJobsByStoryID(id int) error { 98 | store.writeMutex.Lock() 99 | _, err := store.db.Exec(`DELETE FROM hnstories WHERE id = ?`, strconv.Itoa(id)) 100 | if err != nil { 101 | return fmt.Errorf("error deleting story: %v", err) 102 | } 103 | _, err = store.db.Exec(`DELETE FROM hnjobs WHERE parent = ?`, strconv.Itoa(id)) 104 | if err != nil { 105 | return fmt.Errorf("error deleting jobs: %v", err) 106 | } 107 | store.writeMutex.Unlock() 108 | return nil 109 | } 110 | 111 | const storySelect = "SELECT id, kids, time, title, fetched_time FROM hnstories " 112 | 113 | func unmarshalStoryRow(row scannableRow) (*hn.Story, error) { 114 | s := hn.Story{} 115 | kids := sql.NullString{} 116 | err := row.Scan(&s.Id, &kids, &s.Time, &s.Title, &s.FetchedTime) 117 | if err != nil { 118 | return &hn.Story{}, err 119 | } 120 | s.GoTime = time.Unix(s.Time, 0) 121 | s.FetchedGoTime = time.Unix(s.FetchedTime, 0) 122 | if kids.Valid { 123 | err = json.Unmarshal([]byte(kids.String), &s.Kids) 124 | if err != nil { 125 | log.Fatal(err) 126 | } 127 | } 128 | return &s, nil 129 | } 130 | 131 | func UpsertStory(s *hn.Story) error { 132 | kids, err := json.Marshal(s.Kids) 133 | if err != nil { 134 | log.Fatal(err) 135 | } 136 | store.writeMutex.Lock() 137 | _, err = store.db.Exec( 138 | `INSERT INTO hnstories (id, kids, time, title, fetched_time) VALUES (?, ?, ?, ?, ?) 139 | ON CONFLICT (id) DO UPDATE SET 140 | kids = excluded.kids, time = excluded.time, title = excluded.title, fetched_time = excluded.fetched_time`, 141 | s.Id, nullableString(kids), s.Time, s.Title, s.FetchedTime, 142 | ) 143 | store.writeMutex.Unlock() 144 | if err != nil { 145 | return fmt.Errorf("upsert failed: %v", err) 146 | } 147 | return nil 148 | } 149 | 150 | // === table: hnjobs 151 | 152 | type Job struct { 153 | Id int 154 | Parent int 155 | Company string 156 | Text string 157 | Time int64 158 | GoTime time.Time `json:"-"` 159 | FetchedTime int64 160 | FetchedGoTime time.Time `json:"-"` 161 | ReviewedTime int64 162 | ReviewedGoTime time.Time `json:"-"` 163 | Why []string 164 | WhyNot []string 165 | Score int 166 | Read bool 167 | Interested bool 168 | Priority bool 169 | Applied bool 170 | } 171 | 172 | func UpsertJob(job *Job) error { 173 | if store.jobUpsert == nil { 174 | store.jobUpsert, _ = store.db.Prepare( 175 | `INSERT INTO hnjobs ( 176 | id, parent, company, text, time, fetched_time, 177 | reviewed_time, score, why, why_not, 178 | read, interested, priority, applied 179 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 180 | ON CONFLICT (id) DO UPDATE SET 181 | company=excluded.company, text=excluded.text, time=excluded.time, fetched_time=excluded.fetched_time, 182 | reviewed_time=excluded.reviewed_time, score=excluded.score, why=excluded.why, why_not=excluded.why_not, 183 | read=excluded.read, interested=excluded.interested, priority=excluded.priority, applied=excluded.applied 184 | `, 185 | ) 186 | } 187 | why, err := json.Marshal(job.Why) 188 | if err != nil { 189 | log.Fatal(err) 190 | } 191 | whyNot, err := json.Marshal(job.WhyNot) 192 | if err != nil { 193 | log.Fatal(err) 194 | } 195 | store.writeMutex.Lock() 196 | _, err = store.jobUpsert.Exec( 197 | job.Id, job.Parent, job.Company, job.Text, job.Time, job.FetchedTime, 198 | job.ReviewedTime, job.Score, nullableString(why), nullableString(whyNot), 199 | job.Read, job.Interested, job.Priority, job.Applied, 200 | ) 201 | store.writeMutex.Unlock() 202 | if err != nil { 203 | return fmt.Errorf("upsert failed: %v", err) 204 | } 205 | return nil 206 | } 207 | 208 | func GetAllJobsByStoryId(id int, co JobOrder) ([]*Job, error) { 209 | var jobs []*Job 210 | orderBy := "" 211 | switch co { 212 | case OrderNone: 213 | orderBy = "id" 214 | case OrderScoreDesc: 215 | orderBy = "score DESC" 216 | case OrderScoreAsc: 217 | orderBy = "score ASC" 218 | case OrderTimeNewestFirst: 219 | orderBy = "time DESC" 220 | case OrderTimeOldestFirst: 221 | orderBy = "time ASC" 222 | default: 223 | panic(errors.New("unhandled JobOrder")) 224 | } 225 | rows, err := store.db.Query(jobSelect+"WHERE parent = ? ORDER BY "+orderBy, id) 226 | if err != nil { 227 | return jobs, fmt.Errorf("couldn't query: %v", err) 228 | } 229 | for rows.Next() { 230 | job, err := unmarshalJobRow(rows) 231 | if errors.Is(err, sql.ErrNoRows) { 232 | return jobs, ErrNoResults 233 | } else if err != nil { 234 | return jobs, fmt.Errorf("couldn't unmarshal job: %v", err) 235 | } 236 | jobs = append(jobs, job) 237 | } 238 | return jobs, nil 239 | } 240 | 241 | func GetAllJobIDsByStoryID(storyID int) *[]int { 242 | var i []int 243 | // ordered to match the results from HN API 244 | jobRows, err := store.db.Query("SELECT id FROM hnjobs WHERE parent = ? ORDER BY id ASC", storyID) 245 | if err != nil { 246 | log.Fatal(fmt.Sprintf("failed to retrieve job ids: %v", err)) 247 | } 248 | for jobRows.Next() { 249 | var id int 250 | err = jobRows.Scan(&id) 251 | if err != nil { 252 | log.Fatal(fmt.Sprintf("failed to parse existing job storyID: %v", err)) 253 | } 254 | i = append(i, id) 255 | } 256 | return &i 257 | } 258 | 259 | const jobSelect = `SELECT id, parent, company, text, time, fetched_time, 260 | reviewed_time, why, why_not, score, 261 | read, interested, priority, applied FROM hnjobs 262 | ` 263 | 264 | func unmarshalJobRow(row scannableRow) (*Job, error) { 265 | job := Job{} 266 | reviewedTime := sql.NullInt64{} 267 | why := sql.NullString{} 268 | whyNot := sql.NullString{} 269 | err := row.Scan( 270 | &job.Id, &job.Parent, &job.Company, &job.Text, &job.Time, &job.FetchedTime, 271 | &reviewedTime, &why, &whyNot, &job.Score, 272 | &job.Read, &job.Interested, &job.Priority, &job.Applied, 273 | ) 274 | if err != nil { 275 | return &Job{}, err 276 | } 277 | if reviewedTime.Valid { 278 | job.ReviewedTime = reviewedTime.Int64 279 | job.ReviewedGoTime = time.Unix(job.ReviewedTime, 0) 280 | } 281 | if why.Valid { 282 | err = json.Unmarshal([]byte(why.String), &job.Why) 283 | if err != nil { 284 | log.Fatal(err) 285 | } 286 | } 287 | if whyNot.Valid { 288 | err = json.Unmarshal([]byte(whyNot.String), &job.WhyNot) 289 | if err != nil { 290 | log.Fatal(err) 291 | } 292 | } 293 | job.GoTime = time.Unix(job.Time, 0) 294 | job.FetchedGoTime = time.Unix(job.FetchedTime, 0) 295 | return &job, nil 296 | } 297 | 298 | // === util 299 | 300 | // avoid inserting "null" for empty strings 301 | func nullableString(in []byte) sql.NullString { 302 | s := string(in) 303 | out := sql.NullString{} 304 | if s != "null" { 305 | out.Valid = true 306 | out.String = s 307 | } 308 | return out 309 | } 310 | -------------------------------------------------------------------------------- /regionlist/regionlist_test.go: -------------------------------------------------------------------------------- 1 | package regionlist 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRLInsertRegion(t *testing.T) { 8 | 9 | // === case 1 10 | 11 | t.Run("InternalOverlap", func(t *testing.T) { 12 | // AAAAAA 13 | // II 14 | rl := NewRegionList(6, "A") 15 | err := rl.InsertRegion(&Region{ 16 | Start: 2, 17 | End: 3, 18 | Value: "I", 19 | }) 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | rlExpected := &RegionList{ 24 | len: 6, 25 | regions: []*Region{ 26 | {Start: 0, End: 1, Value: "A"}, 27 | {Start: 2, End: 3, Value: "I"}, 28 | {Start: 4, End: 5, Value: "A"}, 29 | }, 30 | } 31 | compareRegionLists(t, rlExpected, rl) 32 | }) 33 | 34 | t.Run("InternalOverlapSame", func(t *testing.T) { 35 | // AAAAAA 36 | // AA 37 | rl := NewRegionList(6, "A") 38 | err := rl.InsertRegion(&Region{ 39 | Start: 2, 40 | End: 3, 41 | Value: "A", 42 | }) 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | rlExpected := &RegionList{ 47 | len: 6, 48 | regions: []*Region{ 49 | {Start: 0, End: 5, Value: "A"}, 50 | }, 51 | } 52 | compareRegionLists(t, rlExpected, rl) 53 | }) 54 | 55 | // === case 3 56 | 57 | t.Run("ExternalOverlap", func(t *testing.T) { 58 | // AABBCCAA 59 | // IIII 60 | rl := &RegionList{ 61 | len: 8, 62 | regions: []*Region{ 63 | {Start: 0, End: 1, Value: "A"}, 64 | {Start: 2, End: 3, Value: "B"}, 65 | {Start: 4, End: 5, Value: "C"}, 66 | {Start: 6, End: 7, Value: "A"}, 67 | }, 68 | } 69 | err := rl.InsertRegion(&Region{ 70 | Start: 2, 71 | End: 5, 72 | Value: "I", 73 | }) 74 | if err != nil { 75 | t.Error(err) 76 | } 77 | rlExpected := &RegionList{ 78 | len: 8, 79 | regions: []*Region{ 80 | {Start: 0, End: 1, Value: "A"}, 81 | {Start: 2, End: 5, Value: "I"}, 82 | {Start: 6, End: 7, Value: "A"}, 83 | }, 84 | } 85 | compareRegionLists(t, rlExpected, rl) 86 | }) 87 | 88 | // case 3, colasce left 89 | t.Run("ExternalOverlapSameLeft", func(t *testing.T) { 90 | // AABBCCDD 91 | // AAAA 92 | rl := &RegionList{ 93 | len: 8, 94 | regions: []*Region{ 95 | {Start: 0, End: 1, Value: "A"}, 96 | {Start: 2, End: 3, Value: "B"}, 97 | {Start: 4, End: 5, Value: "C"}, 98 | {Start: 6, End: 7, Value: "D"}, 99 | }, 100 | } 101 | err := rl.InsertRegion(&Region{ 102 | Start: 2, 103 | End: 5, 104 | Value: "A", 105 | }) 106 | if err != nil { 107 | t.Error(err) 108 | } 109 | rlExpected := &RegionList{ 110 | len: 8, 111 | regions: []*Region{ 112 | {Start: 0, End: 5, Value: "A"}, 113 | {Start: 6, End: 7, Value: "D"}, 114 | }, 115 | } 116 | compareRegionLists(t, rlExpected, rl) 117 | }) 118 | 119 | // case 3, coalesce right 120 | t.Run("ExternalOverlapSameRight", func(t *testing.T) { 121 | // AABBCCDD 122 | // DDDD 123 | rl := &RegionList{ 124 | len: 8, 125 | regions: []*Region{ 126 | {Start: 0, End: 1, Value: "A"}, 127 | {Start: 2, End: 3, Value: "B"}, 128 | {Start: 4, End: 5, Value: "C"}, 129 | {Start: 6, End: 7, Value: "D"}, 130 | }, 131 | } 132 | err := rl.InsertRegion(&Region{ 133 | Start: 2, 134 | End: 5, 135 | Value: "D", 136 | }) 137 | if err != nil { 138 | t.Error(err) 139 | } 140 | rlExpected := &RegionList{ 141 | len: 8, 142 | regions: []*Region{ 143 | {Start: 0, End: 1, Value: "A"}, 144 | {Start: 2, End: 7, Value: "D"}, 145 | }, 146 | } 147 | compareRegionLists(t, rlExpected, rl) 148 | }) 149 | 150 | // case 2/4 151 | 152 | // case 2 & 4 153 | t.Run("Span", func(t *testing.T) { 154 | // AAAABBBB 155 | // IIII 156 | rl := &RegionList{ 157 | len: 8, 158 | regions: []*Region{ 159 | {Start: 0, End: 3, Value: "A"}, 160 | {Start: 4, End: 7, Value: "B"}, 161 | }, 162 | } 163 | err := rl.InsertRegion(&Region{ 164 | Start: 2, 165 | End: 5, 166 | Value: "I", 167 | }) 168 | if err != nil { 169 | t.Error(err) 170 | } 171 | rlExpected := &RegionList{ 172 | len: 8, 173 | regions: []*Region{ 174 | {Start: 0, End: 1, Value: "A"}, 175 | {Start: 2, End: 5, Value: "I"}, 176 | {Start: 6, End: 7, Value: "B"}, 177 | }, 178 | } 179 | compareRegionLists(t, rlExpected, rl) 180 | }) 181 | 182 | // case 4, same left 183 | t.Run("SpanSameLeft", func(t *testing.T) { 184 | // AAAABBBB 185 | // AAAA 186 | rl := &RegionList{ 187 | len: 8, 188 | regions: []*Region{ 189 | {Start: 0, End: 3, Value: "A"}, 190 | {Start: 4, End: 7, Value: "B"}, 191 | }, 192 | } 193 | err := rl.InsertRegion(&Region{ 194 | Start: 2, 195 | End: 5, 196 | Value: "A", 197 | }) 198 | if err != nil { 199 | t.Error(err) 200 | } 201 | rlExpected := &RegionList{ 202 | len: 8, 203 | regions: []*Region{ 204 | {Start: 0, End: 5, Value: "A"}, 205 | {Start: 6, End: 7, Value: "B"}, 206 | }, 207 | } 208 | compareRegionLists(t, rlExpected, rl) 209 | }) 210 | 211 | // case 4, same right 212 | t.Run("SpanSameRight", func(t *testing.T) { 213 | // AAAABBBB 214 | // BBBB 215 | rl := &RegionList{ 216 | len: 8, 217 | regions: []*Region{ 218 | {Start: 0, End: 3, Value: "A"}, 219 | {Start: 4, End: 7, Value: "B"}, 220 | }, 221 | } 222 | err := rl.InsertRegion(&Region{ 223 | Start: 2, 224 | End: 5, 225 | Value: "B", 226 | }) 227 | if err != nil { 228 | t.Error(err) 229 | } 230 | rlExpected := &RegionList{ 231 | len: 8, 232 | regions: []*Region{ 233 | {Start: 0, End: 1, Value: "A"}, 234 | {Start: 2, End: 7, Value: "B"}, 235 | }, 236 | } 237 | compareRegionLists(t, rlExpected, rl) 238 | }) 239 | 240 | //case 4 241 | t.Run("SpanLeftExact", func(t *testing.T) { 242 | // AAAAAA 243 | // II 244 | rl := NewRegionList(6, "A") 245 | err := rl.InsertRegion(&Region{ 246 | Start: 0, 247 | End: 1, 248 | Value: "I", 249 | }) 250 | if err != nil { 251 | t.Error(err) 252 | } 253 | rlExpected := &RegionList{ 254 | len: 6, 255 | regions: []*Region{ 256 | {Start: 0, End: 1, Value: "I"}, 257 | {Start: 2, End: 5, Value: "A"}, 258 | }, 259 | } 260 | compareRegionLists(t, rlExpected, rl) 261 | }) 262 | 263 | // case2 264 | t.Run("SpanRightExact", func(t *testing.T) { 265 | // AAAAAA 266 | // II 267 | rl := NewRegionList(6, "A") 268 | err := rl.InsertRegion(&Region{ 269 | Start: 4, 270 | End: 5, 271 | Value: "I", 272 | }) 273 | if err != nil { 274 | t.Error(err) 275 | } 276 | rlExpected := &RegionList{ 277 | len: 6, 278 | regions: []*Region{ 279 | {Start: 0, End: 3, Value: "A"}, 280 | {Start: 4, End: 5, Value: "I"}, 281 | }, 282 | } 283 | compareRegionLists(t, rlExpected, rl) 284 | }) 285 | 286 | // combo scenarios 287 | 288 | t.Run("ExternalOverlapSpanRight", func(t *testing.T) { 289 | // AABBAAAA 290 | // IIII 291 | rl := &RegionList{ 292 | len: 8, 293 | regions: []*Region{ 294 | {Start: 0, End: 1, Value: "A"}, 295 | {Start: 2, End: 3, Value: "B"}, 296 | {Start: 4, End: 7, Value: "A"}, 297 | }, 298 | } 299 | err := rl.InsertRegion(&Region{ 300 | Start: 2, 301 | End: 5, 302 | Value: "I", 303 | }) 304 | if err != nil { 305 | t.Error(err) 306 | } 307 | rlExpected := &RegionList{ 308 | len: 8, 309 | regions: []*Region{ 310 | {Start: 0, End: 1, Value: "A"}, 311 | {Start: 2, End: 5, Value: "I"}, 312 | {Start: 6, End: 7, Value: "A"}, 313 | }, 314 | } 315 | compareRegionLists(t, rlExpected, rl) 316 | }) 317 | } 318 | 319 | func TestRLResizeAt(t *testing.T) { 320 | t.Run("Grow", func(t *testing.T) { 321 | rl := &RegionList{ 322 | len: 8, 323 | regions: []*Region{ 324 | {Start: 0, End: 1, Value: "A"}, 325 | {Start: 2, End: 3, Value: "B"}, 326 | {Start: 4, End: 7, Value: "A"}, 327 | }, 328 | } 329 | res, err := rl.ResizeAt(3, 2) 330 | if err != nil { 331 | t.Error(err) 332 | } 333 | if res != 10 { 334 | t.Errorf("Expected res to be 10, got %d", res) 335 | } 336 | rlExpected := &RegionList{ 337 | len: 10, 338 | regions: []*Region{ 339 | {Start: 0, End: 1, Value: "A"}, 340 | {Start: 2, End: 5, Value: "B"}, 341 | {Start: 6, End: 9, Value: "A"}, 342 | }, 343 | } 344 | compareRegionLists(t, rlExpected, rl) 345 | }) 346 | 347 | t.Run("Shrink", func(t *testing.T) { 348 | rl := &RegionList{ 349 | len: 10, 350 | regions: []*Region{ 351 | {Start: 0, End: 1, Value: "A"}, 352 | {Start: 2, End: 5, Value: "B"}, 353 | {Start: 6, End: 9, Value: "A"}, 354 | }, 355 | } 356 | res, err := rl.ResizeAt(3, -2) 357 | if err != nil { 358 | t.Error(err) 359 | } 360 | if res != 8 { 361 | t.Errorf("Expected res to be 8, got %d", res) 362 | } 363 | rlExpected := &RegionList{ 364 | len: 8, 365 | regions: []*Region{ 366 | {Start: 0, End: 1, Value: "A"}, 367 | {Start: 2, End: 3, Value: "B"}, 368 | {Start: 4, End: 7, Value: "A"}, 369 | }, 370 | } 371 | compareRegionLists(t, rlExpected, rl) 372 | }) 373 | 374 | // TODO add test cases for invalid inputs 375 | } 376 | 377 | func compareRegionLists(t *testing.T, expected, actual *RegionList) { 378 | if expected.len != actual.len { 379 | t.Errorf("regionLists are different len.\nexpected: %s\nactual: %s\n", expected, actual) 380 | } 381 | if !expected.Equals(actual) { 382 | t.Errorf("regions aren't equal.\nexpected: %s\nactual: %s\n", expected, actual) 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /regionlist/regionlist.go: -------------------------------------------------------------------------------- 1 | package regionlist 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "math" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | // Region represents a single span of a single attribute, e.g. foreground color. Note that unlike slice, End is 13 | // inclusive because this is more natural when working primarily with indices. 14 | type Region struct { 15 | Start int 16 | End int // not exactly necessary but simplifies management 17 | Value string 18 | } 19 | 20 | // RegionList is a simple Run-Length Encoding of the state of a state machine over time (or any other value across an 21 | // integer range.) It ensures that at every point in a range we will always have exactly one value. In this project 22 | // we use RegionList with a string value to represent the various "painted" regions of text, e.g. a range of text where 23 | // the foreground color is blue. 24 | // 25 | // RegionList ensures that all added regions maintain contiguity of the range. RegionList handles region overlaps as 26 | // necessary by splitting regions / deleting / merging / etc. Regions cannot be directly deleted, but they can be 27 | // overwritten with the default value. A RegionList can be expanded/contracted at a specific offset to represent 28 | // document edits. 29 | // 30 | // The intended way to consume a RegionList is via a RegionListManager. 31 | // 32 | // (And yes, for this project we could have used an HTML-to-TTY library, but we still had to do a bunch of text 33 | // processing to insert the correct markup anyway. Plus for future projects I don't want to be tied to HTML as the 34 | // only way to format parsed terminal text.) 35 | type RegionList struct { 36 | len int 37 | regions []*Region 38 | emitListStart bool 39 | emitRegionStart bool 40 | emitRegionEnd bool 41 | emitListEnd bool 42 | } 43 | 44 | func NewRegionList(len int, defaultValue string) *RegionList { 45 | return &RegionList{ 46 | len: len, 47 | regions: []*Region{{Start: 0, End: len - 1, Value: defaultValue}}, 48 | } 49 | } 50 | 51 | func (rl *RegionList) Len() int { 52 | return rl.len 53 | } 54 | 55 | func (rl *RegionList) InsertRegion(newRegion *Region) error { 56 | // sanity check 57 | if newRegion.Start < 0 || 58 | newRegion.End < newRegion.Start || //note: a one-character region is valid and will have the same start and end 59 | newRegion.Start > rl.len || 60 | newRegion.End > rl.len { 61 | log.Fatal(errors.New(fmt.Sprintf("new region is invalid: %#v", newRegion))) 62 | } 63 | 64 | // Cases we have to handle, left to right: 65 | // 1: Internal Overlap 66 | // cur: aaa 67 | // new: i 68 | // action: split A 69 | // 2: Span End 70 | // cur: aaa 71 | // new: iii 72 | // action: resize A.End 73 | // 3: External Overlap 74 | // cur: aaabbb 75 | // new: iii 76 | // action: delete B 77 | // 4: Span Start 78 | // cur: aaabbb 79 | // new: iii 80 | // action: resize B.Start 81 | // 82 | // Special: we can get some variant of all of the above where the new region has the same value as one of 83 | // the existing regions, e.g.: 84 | // cur: aaabbb 85 | // new: aa 86 | // action: resize the current A.end 87 | 88 | // We avoid any "check the whole list" logic for performance since the list could be long, though that 89 | // approach would be much simpler. 90 | 91 | var deleteRegions []int //a range of regions marked for deletion. [0] is start, [1] is end 92 | done := false //need to call finish() from different places and know whether it's already been called 93 | doInsert := true 94 | coalescent := -1 //when expanding a region to avoid adjacency, need to keep track of which region to expand 95 | finish := func(i int) { 96 | if doInsert { 97 | rl.regions = slices.Insert(rl.regions, i, newRegion) 98 | } 99 | if len(deleteRegions) > 0 { 100 | rl.regions = slices.Concat(rl.regions[:deleteRegions[0]], rl.regions[deleteRegions[1]+1:]) 101 | } 102 | done = true 103 | } 104 | for i, curRegion := range rl.regions { 105 | if curRegion.End < newRegion.Start { 106 | //we're not yet into the relevant range 107 | continue 108 | } 109 | if curRegion.Start > newRegion.End { 110 | //we're one iteration past the relevant range, so we are done. 111 | finish(i) 112 | break 113 | } 114 | 115 | // case 1: split curRegion 116 | // cur: aaaa 117 | // new: ii 118 | if curRegion.Start < newRegion.Start && curRegion.End > newRegion.End { 119 | if curRegion.Value == newRegion.Value { 120 | // cur: aaaa 121 | // new: aa 122 | // no reason to make any changes 123 | done = true 124 | break 125 | } 126 | // cur: aaaa 127 | // new: ii 128 | spanEnd := rl.regions[i].End 129 | rl.regions[i].End = newRegion.Start - 1 //trim existing region 130 | rl.regions = slices.Insert(rl.regions, i+1, newRegion) //insert 131 | rl.regions = slices.Insert(rl.regions, i+2, 132 | &Region{Start: newRegion.End + 1, End: spanEnd, Value: rl.regions[i].Value}, 133 | ) //append remainder 134 | // case 1 is mutually exclusive with all others so we're done 135 | done = true 136 | break 137 | } 138 | 139 | // case 2: resize curRegion.End 140 | // cur: aaaa 141 | // new: iiii 142 | if curRegion.Start < newRegion.Start && curRegion.End <= newRegion.End { 143 | if curRegion.Value == newRegion.Value { 144 | // cur: aaabbb 145 | // new: aa 146 | // Simply resize curRegion.End to the right. We'll handle the B side in another iteration 147 | rl.regions[i].End = newRegion.End 148 | doInsert = false 149 | continue 150 | } 151 | // Resize curRegion.End to the left 152 | rl.regions[i].End = newRegion.Start - 1 153 | continue 154 | } 155 | 156 | // case 3: external overlap (possibly multiple) - delete curRegion 157 | // cur: aabbccaa 158 | // new: iiii 159 | if curRegion.Start >= newRegion.Start && curRegion.End <= newRegion.End { 160 | //newRegion overlaps curRegion. Mark it for deletion. 161 | if len(deleteRegions) == 0 { 162 | deleteRegions = append(deleteRegions, i) //start 163 | deleteRegions = append(deleteRegions, i) //end 164 | } else { 165 | //this is not the first curRegion to be overlapped 166 | deleteRegions[1] = i //bump the end 167 | } 168 | if i > 0 && coalescent < 0 && rl.regions[i-1].Value == newRegion.Value { 169 | // Coalescence: 170 | // cur: aabbccdd 171 | // new: aaaa 172 | // We want to expand the prior region rather than insert another. Start a coalescence. 173 | coalescent = i - 1 174 | } 175 | if coalescent >= 0 { 176 | // Should we expand the coalescent or end it? 177 | if rl.regions[coalescent].Value == newRegion.Value { 178 | rl.regions[coalescent].End = curRegion.End // expand the coalescent 179 | newRegion.Start = curRegion.End + 1 // adjust the new region 180 | if newRegion.Start > newRegion.End { 181 | //we're done 182 | doInsert = false 183 | finish(i) 184 | break 185 | } 186 | } else { 187 | //stop the coalescence 188 | coalescent = -1 189 | } 190 | } 191 | // Check to see if the next region could be merged with this one. 192 | // cur: aabbccdd 193 | // new: dddd 194 | if i < len(rl.regions)-1 && // ending in the middle (there are regions after newRegion) 195 | newRegion.End <= rl.regions[i+1].Start && // last loop 196 | newRegion.Value == rl.regions[i+1].Value { // next region has same value 197 | rl.regions[i+1].Start = newRegion.Start 198 | doInsert = false 199 | finish(i) 200 | break 201 | } 202 | continue 203 | } 204 | 205 | // case 4: resize curRegion.Start 206 | // cur: bbb 207 | // new: iii 208 | if newRegion.End < curRegion.End && newRegion.End >= curRegion.Start { 209 | if curRegion.Value == newRegion.Value { 210 | // cur: bbb 211 | // new: bbb 212 | // Resize curRegion to the left 213 | rl.regions[i].Start = newRegion.Start 214 | doInsert = false 215 | finish(i) 216 | break 217 | } 218 | // Resize curRegion to the right 219 | rl.regions[i].Start = newRegion.End + 1 220 | finish(i) 221 | break 222 | } 223 | panic(errors.New(fmt.Sprintf("Unhandled region!\n rl: %s\n i: %d\n newRegion: %v\n", rl, i, newRegion))) 224 | } 225 | if !done { 226 | // newRegion is the last one in the list, so the loop didn't have a chance to call finish() 227 | finish(len(rl.regions)) 228 | } 229 | return nil 230 | } 231 | 232 | func (rl *RegionList) String() string { 233 | var b strings.Builder 234 | b.WriteString(fmt.Sprintf("RegionList{\n len: %d\n", rl.len)) 235 | for i := range rl.regions { 236 | b.WriteString(fmt.Sprintf(" %#v,\n", *rl.regions[i])) 237 | } 238 | b.WriteString("}") 239 | return b.String() 240 | } 241 | 242 | func (rl *RegionList) Equals(other *RegionList) bool { 243 | // faster than reflect.DeepEquals, but maybe only needed for testing?? 244 | if rl.len != other.len { 245 | return false 246 | } 247 | if len(rl.regions) != len(other.regions) { 248 | return false 249 | } 250 | for i := range rl.regions { 251 | if !(*rl.regions[i] == *other.regions[i]) { 252 | return false 253 | } 254 | } 255 | return true 256 | } 257 | 258 | func (rl *RegionList) ResizeAt(offset int, amount int) (newLen int, e error) { 259 | if amount < 0 && int(math.Abs(float64(amount))) > rl.len { 260 | return rl.len, errors.New(fmt.Sprintf("amount to shrink (%d) is greater than list len (%d)", amount, rl.len)) 261 | } 262 | if amount == 0 { 263 | return rl.len, nil 264 | } 265 | if offset < 0 { 266 | return rl.len, errors.New(fmt.Sprintf("index %d is less than 0", offset)) 267 | } 268 | if offset > rl.len { 269 | return rl.len, errors.New(fmt.Sprintf("index %d is greater than list len %d", offset, rl.len)) 270 | } 271 | 272 | // find region at this offset 273 | startRegionIndex := -1 274 | for i, r := range rl.regions { 275 | if offset > r.End { 276 | continue 277 | } 278 | startRegionIndex = i 279 | break 280 | } 281 | if startRegionIndex < 0 { 282 | return rl.len, errors.New(fmt.Sprintf("couldn't find region at offset %d", offset)) 283 | } 284 | //adjust this region 285 | if rl.regions[startRegionIndex].End+amount < rl.regions[startRegionIndex].Start { 286 | return rl.len, errors.New(fmt.Sprintf( 287 | "region at offset %d asked to shrink by %d but only has len %d", 288 | offset, 289 | amount, 290 | rl.regions[startRegionIndex].End-rl.regions[startRegionIndex].Start, 291 | )) 292 | } 293 | rl.regions[startRegionIndex].End += amount 294 | //fixup remaining regions 295 | for i := startRegionIndex + 1; i < len(rl.regions); i++ { 296 | rl.regions[i].Start += amount 297 | rl.regions[i].End += amount 298 | } 299 | rl.len += amount 300 | return rl.len, nil 301 | } 302 | -------------------------------------------------------------------------------- /theme/gruvbox.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import "github.com/mwinters0/hnjobs/sanitview" 4 | 5 | type GruvboxIntensity = int 6 | 7 | const ( 8 | GruvboxIntensityHard GruvboxIntensity = iota 9 | GruvboxIntensityNeutral 10 | GruvboxIntensitySoft 11 | ) 12 | 13 | type GruvboxMode = int 14 | 15 | const ( 16 | GruvboxModeDark GruvboxMode = iota 17 | GruvboxModeLight 18 | ) 19 | 20 | type gruvboxSpecific struct { 21 | bg0 string 22 | bg1 string 23 | bg2 string 24 | bg3 string 25 | bg4 string 26 | fg0 string 27 | fg1 string 28 | fg2 string 29 | fg3 string 30 | fg4 string 31 | red string 32 | green string 33 | yellow string 34 | blue string 35 | purple string 36 | aqua string 37 | orange string 38 | neutral_red string 39 | neutral_green string 40 | neutral_yellow string 41 | neutral_blue string 42 | neutral_purple string 43 | neutral_aqua string 44 | dark_red string 45 | dark_green string 46 | dark_aqua string 47 | gray string 48 | } 49 | 50 | func getGruvboxTheme(m GruvboxMode, i GruvboxIntensity) Theme { 51 | // Converted from https://github.com/ellisonleao/gruvbox.nvim/blob/main/lua/gruvbox.lua 52 | var gruvbox = struct { 53 | dark0_hard string 54 | dark0 string 55 | dark0_soft string 56 | dark1 string 57 | dark2 string 58 | dark3 string 59 | dark4 string 60 | light0_hard string 61 | light0 string 62 | light0_soft string 63 | light1 string 64 | light2 string 65 | light3 string 66 | light4 string 67 | bright_red string 68 | bright_green string 69 | bright_yellow string 70 | bright_blue string 71 | bright_purple string 72 | bright_aqua string 73 | bright_orange string 74 | neutral_red string 75 | neutral_green string 76 | neutral_yellow string 77 | neutral_blue string 78 | neutral_purple string 79 | neutral_aqua string 80 | neutral_orange string 81 | faded_red string 82 | faded_green string 83 | faded_yellow string 84 | faded_blue string 85 | faded_purple string 86 | faded_aqua string 87 | faded_orange string 88 | dark_red_hard string 89 | dark_red string 90 | dark_red_soft string 91 | light_red_hard string 92 | light_red string 93 | light_red_soft string 94 | dark_green_hard string 95 | dark_green string 96 | dark_green_soft string 97 | light_green_hard string 98 | light_green string 99 | light_green_soft string 100 | dark_aqua_hard string 101 | dark_aqua string 102 | dark_aqua_soft string 103 | light_aqua_hard string 104 | light_aqua string 105 | light_aqua_soft string 106 | gray string 107 | }{ 108 | dark0_hard: "#1d2021", 109 | dark0: "#282828", 110 | dark0_soft: "#32302f", 111 | dark1: "#3c3836", 112 | dark2: "#504945", 113 | dark3: "#665c54", 114 | dark4: "#7c6f64", 115 | light0_hard: "#f9f5d7", 116 | light0: "#fbf1c7", 117 | light0_soft: "#f2e5bc", 118 | light1: "#ebdbb2", 119 | light2: "#d5c4a1", 120 | light3: "#bdae93", 121 | light4: "#a89984", 122 | bright_red: "#fb4934", 123 | bright_green: "#b8bb26", 124 | bright_yellow: "#fabd2f", 125 | bright_blue: "#83a598", 126 | bright_purple: "#d3869b", 127 | bright_aqua: "#8ec07c", 128 | bright_orange: "#fe8019", 129 | neutral_red: "#cc241d", 130 | neutral_green: "#98971a", 131 | neutral_yellow: "#d79921", 132 | neutral_blue: "#458588", 133 | neutral_purple: "#b16286", 134 | neutral_aqua: "#689d6a", 135 | neutral_orange: "#d65d0e", 136 | faded_red: "#9d0006", 137 | faded_green: "#79740e", 138 | faded_yellow: "#b57614", 139 | faded_blue: "#076678", 140 | faded_purple: "#8f3f71", 141 | faded_aqua: "#427b58", 142 | faded_orange: "#af3a03", 143 | dark_red_hard: "#792329", 144 | dark_red: "#722529", 145 | dark_red_soft: "#7b2c2f", 146 | light_red_hard: "#fc9690", 147 | light_red: "#fc9487", 148 | light_red_soft: "#f78b7f", 149 | dark_green_hard: "#5a633a", 150 | dark_green: "#62693e", 151 | dark_green_soft: "#686d43", 152 | light_green_hard: "#d3d6a5", 153 | light_green: "#d5d39b", 154 | light_green_soft: "#cecb94", 155 | dark_aqua_hard: "#3e4934", 156 | dark_aqua: "#49503b", 157 | dark_aqua_soft: "#525742", 158 | light_aqua_hard: "#e6e9c1", 159 | light_aqua: "#e8e5b5", 160 | light_aqua_soft: "#e1dbac", 161 | gray: "#928374", 162 | } 163 | 164 | var g gruvboxSpecific 165 | switch m { 166 | case GruvboxModeDark: 167 | g = gruvboxSpecific{ 168 | bg1: gruvbox.dark1, 169 | bg2: gruvbox.dark2, 170 | bg3: gruvbox.dark3, 171 | bg4: gruvbox.dark4, 172 | fg1: gruvbox.light1, 173 | fg2: gruvbox.light2, 174 | fg3: gruvbox.light3, 175 | fg4: gruvbox.light4, 176 | red: gruvbox.bright_red, 177 | green: gruvbox.bright_green, 178 | yellow: gruvbox.bright_yellow, 179 | blue: gruvbox.bright_blue, 180 | purple: gruvbox.bright_purple, 181 | aqua: gruvbox.bright_aqua, 182 | orange: gruvbox.bright_orange, 183 | neutral_red: gruvbox.neutral_red, 184 | neutral_green: gruvbox.neutral_green, 185 | neutral_yellow: gruvbox.neutral_yellow, 186 | neutral_blue: gruvbox.neutral_blue, 187 | neutral_purple: gruvbox.neutral_purple, 188 | neutral_aqua: gruvbox.neutral_aqua, 189 | gray: gruvbox.gray, 190 | } 191 | switch i { 192 | case GruvboxIntensitySoft: 193 | g.bg0 = gruvbox.dark0_soft 194 | g.fg0 = gruvbox.light0_soft 195 | g.dark_red = gruvbox.dark_red_soft 196 | g.dark_green = gruvbox.dark_green_soft 197 | g.dark_aqua = gruvbox.dark_aqua_soft 198 | case GruvboxIntensityNeutral: 199 | g.bg0 = gruvbox.dark0 200 | g.fg0 = gruvbox.light0 201 | g.dark_red = gruvbox.dark_red 202 | g.dark_green = gruvbox.dark_green 203 | g.dark_aqua = gruvbox.dark_aqua 204 | case GruvboxIntensityHard: 205 | g.bg0 = gruvbox.dark0_hard 206 | g.fg0 = gruvbox.light0_hard 207 | g.dark_red = gruvbox.dark_red_hard 208 | g.dark_green = gruvbox.dark_green_hard 209 | g.dark_aqua = gruvbox.dark_aqua_hard 210 | } 211 | case GruvboxModeLight: 212 | g = gruvboxSpecific{ 213 | bg1: gruvbox.light1, 214 | bg2: gruvbox.light2, 215 | bg3: gruvbox.light3, 216 | bg4: gruvbox.light4, 217 | fg1: gruvbox.dark1, 218 | fg2: gruvbox.dark2, 219 | fg3: gruvbox.dark3, 220 | fg4: gruvbox.dark4, 221 | red: gruvbox.faded_red, 222 | green: gruvbox.faded_green, 223 | yellow: gruvbox.faded_yellow, 224 | blue: gruvbox.faded_blue, 225 | purple: gruvbox.faded_purple, 226 | aqua: gruvbox.faded_aqua, 227 | orange: gruvbox.faded_orange, 228 | neutral_red: gruvbox.neutral_red, 229 | neutral_green: gruvbox.neutral_green, 230 | neutral_yellow: gruvbox.neutral_yellow, 231 | neutral_blue: gruvbox.neutral_blue, 232 | neutral_purple: gruvbox.neutral_purple, 233 | neutral_aqua: gruvbox.neutral_aqua, 234 | gray: gruvbox.gray, 235 | } 236 | switch i { 237 | case GruvboxIntensitySoft: 238 | g.bg0 = gruvbox.light0_soft 239 | g.fg0 = gruvbox.dark0_soft 240 | g.dark_red = gruvbox.light_red_soft 241 | g.dark_green = gruvbox.light_green_soft 242 | g.dark_aqua = gruvbox.light_aqua_soft 243 | case GruvboxIntensityNeutral: 244 | g.bg0 = gruvbox.light0 245 | g.fg0 = gruvbox.dark0 246 | g.dark_red = gruvbox.light_red 247 | g.dark_green = gruvbox.light_green 248 | g.dark_aqua = gruvbox.light_aqua 249 | case GruvboxIntensityHard: 250 | g.bg0 = gruvbox.light0_hard 251 | g.fg0 = gruvbox.dark0_hard 252 | g.dark_red = gruvbox.light_red_hard 253 | g.dark_green = gruvbox.light_green_hard 254 | g.dark_aqua = gruvbox.light_aqua_hard 255 | } 256 | } 257 | 258 | bg := g.bg0 259 | normal := &sanitview.TViewStyle{Fg: g.fg1, Bg: bg} 260 | dim := &sanitview.TViewStyle{Fg: g.gray} 261 | priority := &sanitview.TViewStyle{Fg: bg, Bg: g.neutral_yellow} 262 | frameHeaders := &sanitview.TViewStyle{Fg: g.fg1, Bg: g.dark_green} 263 | 264 | return Theme{ 265 | Version: 1, 266 | UI: UIColors{ 267 | HeaderStatsDate: &sanitview.TViewStyle{Fg: g.fg1, Bg: g.dark_green}, 268 | HeaderStatsNormal: &sanitview.TViewStyle{Fg: g.fg2, Bg: g.dark_aqua}, 269 | HeaderStatsHidden: &sanitview.TViewStyle{Fg: g.fg4, Bg: g.bg1}, 270 | FocusBorder: &sanitview.TViewStyle{Fg: g.fg3}, 271 | ModalTitle: &sanitview.TViewStyle{Fg: g.orange, Bg: bg}, 272 | ModalNormal: normal, 273 | ModalHighlight: &sanitview.TViewStyle{Fg: g.aqua, Bg: bg}, 274 | }, 275 | CompanyList: CompanyList{ 276 | Colors: CompanyListColors{ 277 | CompanyName: &sanitview.TViewStyle{Fg: g.neutral_blue, Bg: bg}, 278 | CompanyNameUnread: &sanitview.TViewStyle{Attrs: "b"}, 279 | CompanyNamePriority: priority, 280 | CompanyNameApplied: &sanitview.TViewStyle{}, 281 | CompanyNameUninterested: dim, 282 | StatusChar: normal, 283 | StatusCharUnread: &sanitview.TViewStyle{Fg: g.purple, Attrs: "b"}, 284 | StatusCharPriority: priority, 285 | StatusCharApplied: &sanitview.TViewStyle{Fg: g.green}, 286 | StatusCharUninterested: dim, 287 | Score: normal, 288 | ScoreUnread: &sanitview.TViewStyle{Attrs: "b"}, 289 | ScorePriority: priority, 290 | ScoreApplied: &sanitview.TViewStyle{}, 291 | ScoreUninterested: dim, 292 | SelectedItemBackground: &sanitview.TViewStyle{Bg: g.bg1}, 293 | FrameBackground: &sanitview.TViewStyle{Bg: bg}, 294 | FrameHeader: frameHeaders, 295 | }, 296 | Chars: CompanyListChars{ 297 | Read: " ", 298 | Unread: "*", 299 | Applied: "🗹", 300 | Uninterested: "⨯", 301 | Priority: "★", 302 | }, 303 | }, 304 | JobBody: JobBodyColors{ 305 | Normal: normal, 306 | CompanyName: &sanitview.TViewStyle{Fg: g.blue}, 307 | URL: &sanitview.TViewStyle{Fg: g.neutral_yellow, Attrs: "u"}, 308 | Email: &sanitview.TViewStyle{Fg: g.aqua}, 309 | PositiveHit: &sanitview.TViewStyle{Fg: g.green}, 310 | NegativeHit: &sanitview.TViewStyle{Fg: g.red}, 311 | Pre: &sanitview.TViewStyle{Fg: g.fg4}, 312 | FrameBackground: &sanitview.TViewStyle{Bg: bg}, 313 | FrameHeader: frameHeaders, 314 | }, 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= 2 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 7 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 8 | github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= 9 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= 10 | github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= 11 | github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 14 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 18 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 24 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 26 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 30 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 31 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026 h1:ij8h8B3psk3LdMlqkfPTKIzeGzTaZLOiyplILMlxPAM= 32 | github.com/rivo/tview v0.0.0-20250501113434-0c592cd31026/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 35 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 36 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 37 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 38 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 39 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 40 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 41 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 42 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 43 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 45 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 46 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 47 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 48 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 49 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 50 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 51 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 52 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 53 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 54 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 55 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 56 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 57 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 58 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 59 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 60 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 61 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 62 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 63 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 64 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 65 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 66 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 67 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 69 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 70 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 71 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 72 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 73 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 74 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 75 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 76 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 77 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 86 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 87 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 88 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 89 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 90 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 91 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 92 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 93 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 94 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 95 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 96 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 97 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 98 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 99 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 100 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 101 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 102 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 103 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 104 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 105 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 106 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 107 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 108 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 109 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 110 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 111 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 112 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 113 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 114 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 115 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 116 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 117 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 118 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 119 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 120 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 123 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 124 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 125 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 126 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 127 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 128 | modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= 129 | modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 130 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 131 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 132 | modernc.org/goabi0 v0.0.3 h1:y81b9r3asCh6Xtse6Nz85aYGB0cG3M3U6222yap1KWI= 133 | modernc.org/goabi0 v0.0.3/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 134 | modernc.org/libc v1.66.1 h1:4uQsntXbVyAgrV+j6NhKvDiUypoJL48BWQx6sy9y8ok= 135 | modernc.org/libc v1.66.1/go.mod h1:AiZxInURfEJx516LqEaFcrC+X38rt9G7+8ojIXQKHbo= 136 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 137 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 138 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 139 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 140 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 141 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 142 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 143 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 144 | modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= 145 | modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= 146 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 147 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 148 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 149 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 150 | -------------------------------------------------------------------------------- /app/fetch.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/mwinters0/hnjobs/db" 8 | "github.com/mwinters0/hnjobs/hn" 9 | "github.com/mwinters0/hnjobs/scoring" 10 | "html" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | "time" 15 | ) 16 | 17 | var existingJobs map[int]*db.Job // key is job ID 18 | var numNewJobsFetched atomic.Int32 19 | var numUpdatedJobsFetched atomic.Int32 20 | var numCommentsFetched atomic.Int32 21 | 22 | type UpdateType = int 23 | 24 | const ( 25 | UpdateTypeGeneric UpdateType = iota 26 | UpdateTypeNewStory // value is new story id 27 | UpdateTypeNonFatalErr 28 | UpdateTypeFatal 29 | UpdateTypeBadComment 30 | UpdateTypeJobFetched 31 | UpdateTypeDone // value is newJobs + updatedJobs 32 | ) 33 | 34 | type FetchStatusUpdate struct { 35 | UpdateType UpdateType 36 | Message string 37 | Value int // either new job score or number of jobs fetched on completion 38 | Error error 39 | } 40 | 41 | const WhoIsHiringString string = "Who is hiring?" 42 | 43 | // todo? make this config-driven 44 | const maxCompanyNameLength = 30 // is not the same const in app/browse 45 | 46 | type FetchOptions struct { 47 | Context context.Context 48 | Status chan<- FetchStatusUpdate 49 | ModeForce bool 50 | StoryID int // fetch latest if 0 51 | TTLSec int64 52 | MustContain string // typically "Who's Hiring" 53 | } 54 | 55 | func genericStatus(s string, c chan<- FetchStatusUpdate) { 56 | c <- FetchStatusUpdate{UpdateTypeGeneric, s, 0, nil} 57 | } 58 | 59 | func FetchAsync(fo FetchOptions) { 60 | var err error 61 | existingJobs = make(map[int]*db.Job) // cache for TTL checking 62 | numNewJobsFetched.Store(0) 63 | numUpdatedJobsFetched.Store(0) 64 | numCommentsFetched.Store(0) 65 | storyId := fo.StoryID // if we fetch latest then this val will change 66 | 67 | notifyCompletion := func(msg string, v int, e error, fatal bool) { 68 | // Just a single place to close() on completion 69 | if fatal { 70 | fo.Status <- FetchStatusUpdate{UpdateTypeFatal, msg, v, e} 71 | } else { 72 | fo.Status <- FetchStatusUpdate{UpdateTypeDone, msg, v, e} 73 | } 74 | close(fo.Status) 75 | } 76 | 77 | isNewStory := false 78 | var apiStory *hn.Story 79 | if storyId == 0 { 80 | // get latest 81 | genericStatus("Fetching job stories...", fo.Status) 82 | submissions, err := hn.FetchSubmissions(fo.Context, "whoishiring") 83 | if err != nil { 84 | notifyCompletion("Error fetching job stories", 0, err, true) 85 | return 86 | } 87 | genericStatus(fmt.Sprintf("Found %d job stories...", len(submissions)), fo.Status) 88 | if fo.MustContain != "" { 89 | genericStatus(fmt.Sprintf("Searching for most-recent '%s' story...", fo.MustContain), fo.Status) 90 | for i := range submissions { 91 | s, err := hn.FetchStory(fo.Context, submissions[i]) 92 | if err != nil { 93 | notifyCompletion(fmt.Sprintf("Failed to retrieve job story %d from API.", submissions[i]), 0, err, true) 94 | return 95 | } 96 | if strings.Contains(s.Title, fo.MustContain) { 97 | apiStory = s 98 | break 99 | } 100 | } 101 | if apiStory == nil { 102 | notifyCompletion(fmt.Sprintf("Couldn't find a job story matching '%s'", fo.MustContain), 0, err, true) 103 | return 104 | } 105 | } else { 106 | apiStory, err = hn.FetchStory(fo.Context, submissions[0]) 107 | if err != nil { 108 | notifyCompletion(fmt.Sprintf("Failed to retrieve job story %d from API.", submissions[0]), 0, err, true) 109 | return 110 | } 111 | } 112 | storyId = apiStory.Id 113 | } else { 114 | apiStory, err = hn.FetchStory(fo.Context, storyId) 115 | if err != nil { 116 | notifyCompletion("Failed to retrieve job story from API.", 0, err, true) 117 | return 118 | } 119 | } 120 | 121 | // get story comment IDs 122 | 123 | dbStory, err := db.GetStoryById(storyId) 124 | if err != nil && !errors.Is(err, db.ErrNoResults) { 125 | notifyCompletion("Failure checking DB for existing story.", 0, err, true) 126 | return 127 | } 128 | if errors.Is(err, db.ErrNoResults) { 129 | isNewStory = true 130 | fo.Status <- FetchStatusUpdate{ 131 | UpdateTypeNewStory, 132 | fmt.Sprintf("Found NEW job story: \"%s\" (%d top-level comments)", apiStory.Title, len(apiStory.Kids)), 133 | apiStory.Id, 134 | nil, 135 | } 136 | // We don't want to set fetched_time in the DB until after we've completed the fetch 137 | apiStory.FetchedTime = 0 138 | apiStory.FetchedGoTime = time.Unix(0, 0) 139 | err = db.UpsertStory(apiStory) 140 | if err != nil { 141 | notifyCompletion("Failed to upsert story.", 0, err, true) 142 | return 143 | } 144 | } else { 145 | genericStatus(fmt.Sprintf("Most-recent job story (%d) was previously cached", storyId), fo.Status) 146 | } 147 | 148 | // if we're using story-based ttl, then we only have two modes: fetch all (ttl expired), or fetch new. 149 | // However, we want to defer updating the story fetch time until after fetch is complete. 150 | 151 | var commentIDsToFetch []int 152 | if isNewStory { 153 | commentIDsToFetch = apiStory.Kids 154 | genericStatus("Fetching all top-level comments...", fo.Status) 155 | } else { 156 | // When fetching a job, it might be an update of an existing job. We then want to set unread while 157 | // preserving the rest of the user-created state. 158 | jobs, err := db.GetAllJobsByStoryId(storyId, db.OrderNone) 159 | if err != nil && errors.Is(err, db.ErrNoResults) { 160 | notifyCompletion("Failed to fetch existing jobs from the DB", 0, err, true) 161 | return 162 | } 163 | for _, job := range jobs { 164 | existingJobs[job.Id] = job 165 | } 166 | //decide what we're fetching 167 | if fo.ModeForce { 168 | // fetch all 169 | commentIDsToFetch = apiStory.Kids 170 | genericStatus("ModeForce-fetching all top-level comments...", fo.Status) 171 | } else { 172 | // check story TTL 173 | age := time.Now().UTC().Unix() - dbStory.FetchedTime 174 | if age > fo.TTLSec { 175 | // expired TTL - fetch all 176 | genericStatus( 177 | fmt.Sprintf("Story TTL is expired (age %d, TTL is %d) - fetching all", age, fo.TTLSec), 178 | fo.Status, 179 | ) 180 | commentIDsToFetch = apiStory.Kids 181 | } else { 182 | // only fetch new 183 | genericStatus( 184 | fmt.Sprintf("Story TTL is not expired (age %d, TTL is %d) - only fetching new", age, fo.TTLSec), 185 | fo.Status, 186 | ) 187 | for _, id := range apiStory.Kids { 188 | if _, ok := existingJobs[id]; !ok { 189 | commentIDsToFetch = append(commentIDsToFetch, id) 190 | } 191 | } 192 | existingJobs = make(map[int]*db.Job) // free this memory / speed up this search 193 | } 194 | } 195 | } 196 | 197 | // pipeline: produce comment IDs -> fetch comments -> score and store -> done 198 | // 199 | // producer 200 | commentIDs := make(chan int) 201 | produce := func() { 202 | for _, commentID := range commentIDsToFetch { 203 | select { 204 | case <-fo.Context.Done(): 205 | close(commentIDs) 206 | return 207 | default: 208 | commentIDs <- commentID 209 | } 210 | } 211 | close(commentIDs) 212 | } 213 | // fetchers 214 | numWorkers := 3 215 | comments := make(chan *hn.Comment) 216 | workerUpdates := make(chan FetchStatusUpdate) 217 | fwg := sync.WaitGroup{} 218 | fwg.Add(numWorkers) 219 | for i := 0; i < numWorkers; i++ { 220 | go commentFetcher(fo.Context, &fwg, commentIDs, comments, workerUpdates) 221 | } 222 | // processors 223 | pwg := sync.WaitGroup{} 224 | pwg.Add(numWorkers) 225 | for i := 0; i < numWorkers; i++ { 226 | go commentProcessor(fo.Context, &pwg, comments, workerUpdates) 227 | } 228 | // done waiter 229 | workersDone := make(chan int) 230 | go func() { 231 | fwg.Wait() 232 | close(comments) 233 | pwg.Wait() 234 | close(workersDone) 235 | }() 236 | 237 | go produce() 238 | 239 | totalJobsFetched := func() int { 240 | return int(numNewJobsFetched.Load() + numUpdatedJobsFetched.Load()) 241 | } 242 | // Wait for one of the following: 243 | // 1. cancellation 244 | // 2. all comments to be finished 245 | // 3. a fatal error from a worker (cancel all other workers) 246 | for { 247 | select { 248 | case <-fo.Context.Done(): //1 249 | notifyCompletion("Fetching cancelled", totalJobsFetched(), nil, false) 250 | return 251 | case <-workersDone: //2 252 | // update the fetched_time on the story for TTL 253 | apiStory.FetchedGoTime = time.Now() 254 | apiStory.FetchedTime = apiStory.FetchedGoTime.UTC().Unix() 255 | err = db.UpsertStory(apiStory) 256 | if err != nil { 257 | notifyCompletion("Failed to upsert story.", totalJobsFetched(), err, true) 258 | return 259 | } 260 | notifyCompletion( 261 | fmt.Sprintf( 262 | "Done. Fetched %d new jobs, %d updated jobs (%d comments).", 263 | numNewJobsFetched.Load(), 264 | numUpdatedJobsFetched.Load(), 265 | numCommentsFetched.Load(), 266 | ), 267 | int(numNewJobsFetched.Load()+numUpdatedJobsFetched.Load()), 268 | nil, 269 | false, 270 | ) 271 | return 272 | case wStatus := <-workerUpdates: //this channel should never close 273 | if wStatus.UpdateType == UpdateTypeFatal { //3 274 | notifyCompletion(wStatus.Message, wStatus.Value, wStatus.Error, true) 275 | return 276 | } 277 | fo.Status <- wStatus 278 | } 279 | } 280 | } 281 | 282 | func commentFetcher(ctx context.Context, wg *sync.WaitGroup, commentIDs <-chan int, comments chan<- *hn.Comment, status chan<- FetchStatusUpdate) { 283 | for { 284 | select { 285 | case <-ctx.Done(): 286 | //cancelled 287 | wg.Done() 288 | return 289 | case i, ok := <-commentIDs: 290 | if !ok { 291 | //EOF 292 | wg.Done() 293 | return 294 | } 295 | c, err := hn.FetchComment(ctx, i) 296 | if err != nil { 297 | status <- FetchStatusUpdate{ 298 | UpdateTypeNonFatalErr, 299 | fmt.Sprintf("Failed to fetch comment id %d from API! Ignoring.", i), 300 | 0, 301 | err, 302 | } 303 | // TODO retry? backoff? 304 | continue 305 | } 306 | numCommentsFetched.Add(1) 307 | if len(c.Text) == 0 { 308 | status <- FetchStatusUpdate{ 309 | UpdateTypeBadComment, 310 | fmt.Sprintf("Got empty comment id %d from API, ignoring", i), 311 | 0, 312 | errors.New("empty"), 313 | } 314 | continue 315 | } 316 | comments <- c 317 | } 318 | } 319 | } 320 | 321 | // commentProcessor converts a hn Comment to a job, scores it, and stores it in the DB. 322 | func commentProcessor(ctx context.Context, wg *sync.WaitGroup, comments <-chan *hn.Comment, status chan<- FetchStatusUpdate) { 323 | for { 324 | select { 325 | case <-ctx.Done(): 326 | //cancelled 327 | wg.Done() 328 | return 329 | case c, ok := <-comments: 330 | if !ok { 331 | //EOF 332 | wg.Done() 333 | return 334 | } 335 | if len(c.Text) == 0 { 336 | status <- FetchStatusUpdate{ 337 | UpdateTypeBadComment, 338 | fmt.Sprintf("Bad comment (id %d): empty comment", c.Id), 339 | 0, 340 | errors.New("len==0"), 341 | } 342 | continue 343 | } 344 | if !strings.Contains(c.Text, "|") { 345 | status <- FetchStatusUpdate{ 346 | UpdateTypeBadComment, 347 | fmt.Sprintf("Bad comment (id %d): not a job comment", c.Id), 348 | 0, 349 | errors.New("doesn't contain \"|\""), 350 | } 351 | continue 352 | } 353 | cname, err := getCompanyName(c.Text, maxCompanyNameLength) 354 | if err != nil { 355 | status <- FetchStatusUpdate{ 356 | UpdateTypeBadComment, 357 | fmt.Sprintf("Bad comment (id %d): couldn't find company name", c.Id), 358 | 0, 359 | err, 360 | } 361 | continue 362 | } 363 | job, err := newJobFromHNComment(c, cname) 364 | if err != nil { 365 | status <- FetchStatusUpdate{ 366 | UpdateTypeNonFatalErr, 367 | fmt.Sprintf("Unable to process comment ID %d: %v", c.Id, err), 368 | 0, 369 | err, 370 | } 371 | continue 372 | } 373 | job.FetchedTime = time.Now().UTC().Unix() 374 | score := scoring.ScoreDBComment(job) 375 | // check existing 376 | existingJob, found := existingJobs[c.Id] 377 | if found { 378 | // preserve user state 379 | job.Applied = existingJob.Applied 380 | job.Priority = existingJob.Priority 381 | job.Interested = existingJob.Interested 382 | if job.Text != existingJob.Text { 383 | // text changed since last time we saw it 384 | numUpdatedJobsFetched.Add(1) 385 | job.Read = false 386 | } else { 387 | job.Read = existingJob.Read 388 | } 389 | } else { 390 | numNewJobsFetched.Add(1) 391 | } 392 | err = db.UpsertJob(job) 393 | if err != nil { 394 | //fatal 395 | status <- FetchStatusUpdate{ 396 | UpdateTypeFatal, 397 | fmt.Sprintf("Failed to upsert job into DB!"), 398 | 0, 399 | err, 400 | } 401 | wg.Done() 402 | return 403 | } 404 | status <- FetchStatusUpdate{ 405 | UpdateTypeJobFetched, 406 | fmt.Sprintf("New job (%d): [Score %d]", c.Id, score), 407 | score, 408 | nil, 409 | } 410 | } 411 | } 412 | } 413 | 414 | func newJobFromHNComment(c *hn.Comment, companyName string) (*db.Job, error) { 415 | job := &db.Job{ 416 | Id: c.Id, 417 | Parent: c.Parent, 418 | Company: companyName, 419 | Text: c.Text, 420 | Time: c.Time, 421 | GoTime: c.GoTime, 422 | FetchedTime: c.FetchedTime, 423 | FetchedGoTime: c.FetchedGoTime, 424 | Read: false, 425 | Interested: true, 426 | Priority: false, 427 | Applied: false, 428 | } 429 | return job, nil 430 | } 431 | 432 | func getCompanyName(text string, maxlen int) (string, error) { 433 | if len(text) == 0 { 434 | return "", errors.New("text len is 0") 435 | } 436 | text = html.UnescapeString(text) 437 | i := strings.IndexAny(text, "(|<\n") 438 | if i < 1 { 439 | // Note: some jokers put a URL as the company name, causing this. We could special-case that, but maybe 440 | // just don't work for jokers? You're welcome. 441 | return "", errors.New("no text before first delimiter") 442 | } 443 | if i > maxlen { 444 | i = maxlen 445 | } 446 | return strings.Clone(strings.TrimSpace(text[:i])), nil 447 | } 448 | -------------------------------------------------------------------------------- /app/browse.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/mwinters0/hnjobs/config" 10 | "github.com/mwinters0/hnjobs/db" 11 | "github.com/mwinters0/hnjobs/hn" 12 | "github.com/mwinters0/hnjobs/regionlist" 13 | "github.com/mwinters0/hnjobs/sanitview" 14 | "github.com/mwinters0/hnjobs/scoring" 15 | "github.com/mwinters0/hnjobs/theme" 16 | "github.com/rivo/tview" 17 | "html" 18 | "regexp" 19 | "strconv" 20 | "strings" 21 | "time" 22 | ) 23 | 24 | // -- UI elements 25 | 26 | // DisplayJob is a db.Job formatted for display 27 | type DisplayJob struct { 28 | *db.Job 29 | DisplayCompany string // what's displayed in the list, e.g. "* [7] McDonald's" 30 | DisplayText string // job text formatted for terminal 31 | Hidden bool 32 | } 33 | 34 | var displayJobs []*DisplayJob 35 | 36 | var screenSize = struct { 37 | X int 38 | Y int 39 | }{} 40 | 41 | var tvApp *tview.Application 42 | var tcellScreen tcell.Screen 43 | var companyList *tview.List 44 | var headerText *tview.TextView 45 | var jobText *tview.TextView 46 | var jobFrame *tview.Frame 47 | var pages *tview.Pages 48 | var showingModal bool 49 | var pageScrollAmount int = 10 50 | var prevSelectedJob = -1 //for listNavHandler() 51 | 52 | var displayStats = struct { 53 | numTotal int //redundant but separates concerns 54 | numBelowThreshold int 55 | numUninterested int 56 | numUninterestedDisplayed int // for deciding whether 'x' performs "show uninterested" or "hide" 57 | numHidden int 58 | }{ 59 | 0, 0, 0, 0, 0, 60 | } 61 | 62 | type DisplayStory struct { 63 | *hn.Story 64 | DisplayTitle string 65 | } 66 | 67 | var displayOptions = struct { 68 | threshold int 69 | showBelowThreshold bool 70 | showUninterested bool 71 | curStory *DisplayStory 72 | urlFootnotes bool 73 | }{ 74 | threshold: 1, 75 | showBelowThreshold: false, 76 | showUninterested: false, 77 | curStory: &DisplayStory{Story: &hn.Story{Id: 0}}, 78 | urlFootnotes: false, 79 | } 80 | 81 | // TODO make me responsive 82 | const maxCompanyNameDisplayLength = 20 83 | 84 | var curTheme theme.Theme 85 | 86 | func reset() { 87 | // TODO cleanup 88 | displayOptions = struct { 89 | threshold int 90 | showBelowThreshold bool 91 | showUninterested bool 92 | curStory *DisplayStory 93 | urlFootnotes bool 94 | }{ 95 | threshold: 1, 96 | showBelowThreshold: false, 97 | showUninterested: false, 98 | curStory: &DisplayStory{Story: &hn.Story{Id: 0}}, 99 | urlFootnotes: false, 100 | } 101 | displayStats = struct { 102 | numTotal int 103 | numBelowThreshold int 104 | numUninterested int 105 | numUninterestedDisplayed int 106 | numHidden int 107 | }{ 108 | 0, 0, 0, 0, 0, 109 | } 110 | displayJobs = []*DisplayJob{} 111 | showingModal = false 112 | companyList.Clear() 113 | headerText.Clear() 114 | jobText.Clear() 115 | prevSelectedJob = -1 116 | } 117 | 118 | // -- misc 119 | 120 | func maybePanic(err error) { 121 | if err != nil { 122 | tvApp.Suspend(func() { 123 | panic(err) 124 | }) 125 | } 126 | } 127 | 128 | var preRegex *regexp.Regexp 129 | var italicRegex *regexp.Regexp 130 | var linkRegex *regexp.Regexp 131 | var emailRegex *regexp.Regexp 132 | 133 | // formats the job for TTY 134 | func newDisplayJob(job *db.Job) *DisplayJob { 135 | var err error 136 | dj := &DisplayJob{ 137 | Job: job, 138 | DisplayCompany: formatDisplayCompany(job), 139 | } 140 | if job.Score < displayOptions.threshold && !displayOptions.showBelowThreshold { 141 | dj.Hidden = true 142 | } 143 | if !job.Interested && !displayOptions.showUninterested { 144 | dj.Hidden = true 145 | } 146 | 147 | str := job.Text 148 | str = html.UnescapeString(str) 149 | str = strings.ReplaceAll(str, "

", "\n\n") 150 | str = strings.ReplaceAll(str, "", "") // We always have

 so don't need both
 151 | 	str = strings.ReplaceAll(str, "", "")
 152 | 	str = tview.Escape(str)
 153 | 	str = fmt.Sprintf( // as html so it can get formatted later along with other links
 154 | 		"%s\n\n\n%s►%s Original HN comment:  ",
 155 | 		str,
 156 | 		curTheme.JobBody.CompanyName.AsTag(),
 157 | 		curTheme.JobBody.Normal.AsTag(),
 158 | 		job.Id,
 159 | 	)
 160 | 
 161 | 	const (
 162 | 		keyFg int = iota
 163 | 		keyBg
 164 | 		keyAttr
 165 | 		keyURL
 166 | 	)
 167 | 	rlm := regionlist.NewRegionListManager(len(str))
 168 | 	err = rlm.CreateRegionList(keyFg, curTheme.JobBody.Normal.Fg)
 169 | 	if err != nil {
 170 | 		panic(err)
 171 | 	}
 172 | 	err = rlm.CreateRegionList(keyBg, curTheme.JobBody.Normal.Bg)
 173 | 	if err != nil {
 174 | 		panic(err)
 175 | 	}
 176 | 	err = rlm.CreateRegionList(keyAttr, "-")
 177 | 	if err != nil {
 178 | 		panic(err)
 179 | 	}
 180 | 	err = rlm.CreateRegionList(keyURL, "-")
 181 | 	if err != nil {
 182 | 		panic(err)
 183 | 	}
 184 | 
 185 | 	// helpers
 186 | 	addStyleRegion := func(start int, end int, style *sanitview.TViewStyle) {
 187 | 		if style.Fg != "" {
 188 | 			err = rlm.InsertRegion(keyFg, ®ionlist.Region{
 189 | 				Start: start,
 190 | 				End:   end,
 191 | 				Value: style.Fg,
 192 | 			})
 193 | 			if err != nil {
 194 | 				panic(err)
 195 | 			}
 196 | 		}
 197 | 		if style.Bg != "" {
 198 | 			err = rlm.InsertRegion(keyBg, ®ionlist.Region{
 199 | 				Start: start,
 200 | 				End:   end,
 201 | 				Value: style.Bg,
 202 | 			})
 203 | 			if err != nil {
 204 | 				panic(err)
 205 | 			}
 206 | 		}
 207 | 		if style.Attrs != "" {
 208 | 			err = rlm.InsertRegion(keyAttr, ®ionlist.Region{
 209 | 				Start: start,
 210 | 				End:   end,
 211 | 				Value: style.Attrs,
 212 | 			})
 213 | 			if err != nil {
 214 | 				panic(err)
 215 | 			}
 216 | 		}
 217 | 		if style.Url != "" {
 218 | 			err = rlm.InsertRegion(keyURL, ®ionlist.Region{
 219 | 				Start: start,
 220 | 				End:   end,
 221 | 				Value: style.Url,
 222 | 			})
 223 | 			if err != nil {
 224 | 				panic(err)
 225 | 			}
 226 | 		}
 227 | 	}
 228 | 	processSubmatchRegex := func(r *regexp.Regexp, s string, rlm *regionlist.RegionListManager, style *sanitview.TViewStyle) string {
 229 | 		offset := 0
 230 | 		matches := r.FindAllStringSubmatchIndex(s, -1)
 231 | 		for _, m := range matches {
 232 | 			matchStyle := style.Clone()
 233 | 			matchStart := m[0]
 234 | 			matchEnd := m[1]
 235 | 			matchLen := matchEnd - matchStart
 236 | 			submatchStart := m[2]
 237 | 			submatchEnd := m[3]
 238 | 			submatchLen := submatchEnd - submatchStart
 239 | 			submatchVal := str[submatchStart:submatchEnd]
 240 | 			if submatchLen == 0 {
 241 | 				continue
 242 | 			}
 243 | 			editSizeDelta := submatchLen - matchLen
 244 | 			s = s[:matchStart+offset] + submatchVal + s[matchEnd+offset:]
 245 | 			_, err = rlm.ResizeAt(matchStart+offset+1, editSizeDelta)
 246 | 			if err != nil {
 247 | 				panic(err)
 248 | 			}
 249 | 			if matchStyle.Url == "SUBMATCH" {
 250 | 				matchStyle.Url = submatchVal
 251 | 			}
 252 | 			addStyleRegion(matchStart+offset, matchStart+offset+submatchLen, matchStyle)
 253 | 			offset += editSizeDelta
 254 | 		}
 255 | 		return s
 256 | 	}
 257 | 
 258 | 	// company name
 259 | 	addStyleRegion(0, len(job.Company), curTheme.JobBody.CompanyName)
 260 | 
 261 | 	//pre
 262 | 	if preRegex == nil {
 263 | 		preRegex = regexp.MustCompile(`(?s)
(?P.+?)
`) 264 | } 265 | str = processSubmatchRegex(preRegex, str, rlm, curTheme.JobBody.Pre) 266 | 267 | //italic 268 | if italicRegex == nil { 269 | italicRegex = regexp.MustCompile(`(?s)(?P.+?)`) 270 | } 271 | str = processSubmatchRegex(italicRegex, str, rlm, &sanitview.TViewStyle{Attrs: "i"}) 272 | 273 | // url 274 | if linkRegex == nil { 275 | linkRegex = regexp.MustCompile(`(?U)`) 276 | } 277 | urlStyle := sanitview.MergeTviewStyles(curTheme.JobBody.URL, &sanitview.TViewStyle{Url: "SUBMATCH"}) 278 | str = processSubmatchRegex(linkRegex, str, rlm, urlStyle) 279 | 280 | // email 281 | if emailRegex == nil { 282 | emailRegex = regexp.MustCompile(`(?P[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)`) 283 | } 284 | str = processSubmatchRegex(emailRegex, str, rlm, curTheme.JobBody.Email) 285 | 286 | // rules 287 | for _, r := range scoring.GetRules() { 288 | if r.Colorize != nil && !*r.Colorize { 289 | continue 290 | } 291 | switch r.RuleType { 292 | case scoring.TextFound: 293 | matchIndices := r.Regex.FindAllStringIndex(str, -1) 294 | if matchIndices != nil { 295 | if r.Style == nil { 296 | if r.Score >= 0 { 297 | r.Style = curTheme.JobBody.PositiveHit 298 | } else { 299 | r.Style = curTheme.JobBody.NegativeHit 300 | } 301 | } 302 | for _, pair := range matchIndices { 303 | addStyleRegion(pair[0], pair[1]-1, r.Style) 304 | } 305 | } 306 | } 307 | } 308 | 309 | // apply regions 310 | offset := 0 311 | for e := range rlm.MergedEvents() { 312 | tag := fmt.Sprintf( 313 | "[%s:%s:%s:%s]", 314 | e.Values[keyFg], 315 | e.Values[keyBg], 316 | e.Values[keyAttr], 317 | e.Values[keyURL], 318 | ) 319 | tagLen := len(tag) 320 | totalOffset := e.Offset + offset 321 | str = str[:totalOffset] + tag + str[totalOffset:] 322 | offset += tagLen 323 | } 324 | 325 | dj.DisplayText = str 326 | return dj 327 | } 328 | 329 | func formatDisplayCompany(job *db.Job) string { 330 | //truncate company name for display in list 331 | cname := job.Company 332 | if len(cname) > maxCompanyNameDisplayLength { 333 | scoreLen := len(strconv.Itoa(job.Score)) - 1 // handle large scores - assume size of 1 334 | cname = cname[:maxCompanyNameDisplayLength-3-scoreLen] + "..." 335 | } 336 | 337 | cl := curTheme.CompanyList 338 | 339 | statusChar := cl.Chars.Read 340 | statusCharStyle := cl.Colors.StatusChar 341 | scoreStyle := cl.Colors.Score 342 | nameStyle := cl.Colors.CompanyName 343 | if !job.Interested { 344 | statusCharStyle = sanitview.MergeTviewStyles(statusCharStyle, cl.Colors.StatusCharUninterested) 345 | scoreStyle = sanitview.MergeTviewStyles(scoreStyle, cl.Colors.ScoreUninterested) 346 | nameStyle = sanitview.MergeTviewStyles(nameStyle, cl.Colors.CompanyNameUninterested) 347 | statusChar = cl.Chars.Uninterested 348 | } else { 349 | if !job.Read { 350 | statusCharStyle = sanitview.MergeTviewStyles(statusCharStyle, cl.Colors.StatusCharUnread) 351 | scoreStyle = sanitview.MergeTviewStyles(scoreStyle, cl.Colors.ScoreUnread) 352 | nameStyle = sanitview.MergeTviewStyles(nameStyle, cl.Colors.CompanyNameUnread) 353 | statusChar = cl.Chars.Unread 354 | } 355 | if job.Priority { 356 | statusCharStyle = sanitview.MergeTviewStyles(statusCharStyle, cl.Colors.StatusCharPriority) 357 | scoreStyle = sanitview.MergeTviewStyles(scoreStyle, cl.Colors.ScorePriority) 358 | nameStyle = sanitview.MergeTviewStyles(nameStyle, cl.Colors.CompanyNamePriority) 359 | statusChar = cl.Chars.Priority 360 | } 361 | if job.Applied { 362 | statusCharStyle = sanitview.MergeTviewStyles(statusCharStyle, cl.Colors.StatusCharApplied) 363 | scoreStyle = sanitview.MergeTviewStyles(scoreStyle, cl.Colors.ScoreApplied) 364 | nameStyle = sanitview.MergeTviewStyles(nameStyle, cl.Colors.CompanyNameApplied) 365 | statusChar = cl.Chars.Applied 366 | } 367 | } 368 | statusCharStyleTag := sanitview.StyleToString(statusCharStyle) 369 | scoreStyleTag := sanitview.StyleToString(scoreStyle) 370 | nameStyleTag := sanitview.StyleToString(nameStyle) 371 | 372 | score := tview.Escape(fmt.Sprintf("[%d]", job.Score)) 373 | cname = fmt.Sprintf( 374 | "%s%s %s%s %s%s", 375 | statusCharStyleTag, statusChar, 376 | scoreStyleTag, score, 377 | nameStyleTag, cname, 378 | ) 379 | return cname 380 | } 381 | 382 | func newDisplayStory(s *hn.Story) *DisplayStory { 383 | ds := &DisplayStory{ 384 | Story: s, 385 | DisplayTitle: s.Title[8:], // chop off "Ask HN: " 386 | } 387 | return ds 388 | } 389 | 390 | func setupLatestStory() { 391 | if displayOptions.curStory.Id == 0 { 392 | // get latest 393 | latest, err := db.GetLatestStory() 394 | if errors.Is(err, db.ErrNoResults) { 395 | // probably first run 396 | return 397 | } 398 | if err != nil { 399 | panic(fmt.Errorf("error finding latest job story from DB: %v", err)) 400 | } 401 | displayOptions.curStory = newDisplayStory(latest) 402 | } 403 | } 404 | 405 | func Browse() { 406 | var err error 407 | displayOptions.threshold = config.GetConfig().Display.ScoreThreshold 408 | curTheme = theme.GetTheme() 409 | setupLatestStory() 410 | 411 | tview.Borders = struct { 412 | Horizontal rune 413 | Vertical rune 414 | TopLeft rune 415 | TopRight rune 416 | BottomLeft rune 417 | BottomRight rune 418 | 419 | LeftT rune 420 | RightT rune 421 | TopT rune 422 | BottomT rune 423 | Cross rune 424 | 425 | HorizontalFocus rune 426 | VerticalFocus rune 427 | TopLeftFocus rune 428 | TopRightFocus rune 429 | BottomLeftFocus rune 430 | BottomRightFocus rune 431 | }{ 432 | Horizontal: ' ', // no borders on unfocused elements 433 | Vertical: ' ', 434 | TopLeft: ' ', 435 | TopRight: ' ', 436 | BottomLeft: ' ', 437 | BottomRight: ' ', 438 | 439 | LeftT: tview.BoxDrawingsLightVerticalAndRight, 440 | RightT: tview.BoxDrawingsLightVerticalAndLeft, 441 | TopT: tview.BoxDrawingsLightDownAndHorizontal, 442 | BottomT: tview.BoxDrawingsLightUpAndHorizontal, 443 | Cross: tview.BoxDrawingsLightVerticalAndHorizontal, 444 | 445 | HorizontalFocus: tview.BoxDrawingsLightHorizontal, 446 | VerticalFocus: tview.BoxDrawingsLightVertical, 447 | TopLeftFocus: tview.BoxDrawingsLightDownAndRight, 448 | TopRightFocus: tview.BoxDrawingsLightDownAndLeft, 449 | BottomLeftFocus: tview.BoxDrawingsLightUpAndRight, 450 | BottomRightFocus: tview.BoxDrawingsLightUpAndLeft, 451 | } 452 | 453 | // don't want these on the tvApp because then they fire even when modals are up 454 | sharedRuneHandler := func(r rune) (handled bool) { 455 | switch r { 456 | case 'a': 457 | if !showingModal { 458 | actionListMarkApplied() 459 | } 460 | return true 461 | case 'f': 462 | if !showingModal { 463 | actionConsiderFetch(false) 464 | } 465 | return true 466 | case 'F': 467 | if !showingModal { 468 | actionConsiderFetch(true) 469 | } 470 | return true 471 | case 'm': 472 | if !showingModal { 473 | actionBrowseStories() 474 | } 475 | return true 476 | case 'p': 477 | if !showingModal { 478 | actionListMarkPriority() 479 | } 480 | return true 481 | case 'r': 482 | if !showingModal { 483 | actionListMarkRead() 484 | } 485 | return true 486 | case 's': 487 | if !showingModal { 488 | actionRescore() 489 | } 490 | return true 491 | case 'T': 492 | if !showingModal { 493 | actionToggleShowBelowThreshold() 494 | } 495 | return true 496 | case 'x': 497 | if !showingModal { 498 | actionListMarkInterested() 499 | } 500 | return true 501 | case 'X': 502 | if !showingModal { 503 | actionToggleShowUninterested() 504 | } 505 | return true 506 | } 507 | return false 508 | } 509 | 510 | tvApp = tview.NewApplication() 511 | tvApp.EnableMouse(true) 512 | tvApp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 513 | switch event.Rune() { 514 | case 'q': 515 | tvApp.Stop() 516 | } 517 | return event 518 | }) 519 | 520 | focusColor := tcell.GetColor(curTheme.UI.FocusBorder.Fg) 521 | // list 522 | listBg := tcell.GetColor(curTheme.CompanyList.Colors.FrameBackground.Bg) 523 | companyList = tview.NewList(). // list attrs 524 | ShowSecondaryText(false). 525 | SetWrapAround(false). 526 | SetChangedFunc(listNavHandler). 527 | SetSelectedBackgroundColor(tcell.GetColor(curTheme.CompanyList.Colors.SelectedItemBackground.Bg)) 528 | companyList. // Box attrs 529 | SetBackgroundColor(listBg) 530 | companyList.SetHighlightFullLine(true) 531 | companyList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 532 | if sharedRuneHandler(event.Rune()) { 533 | return nil 534 | } 535 | switch event.Rune() { 536 | case 'g': 537 | companyList.SetCurrentItem(companyList.GetItemCount()) 538 | return nil 539 | case 'G': 540 | companyList.SetCurrentItem(0) 541 | return nil 542 | case 'j': 543 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 544 | case 'k': 545 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 546 | } 547 | switch event.Key() { 548 | case tcell.KeyTab: 549 | tvApp.SetFocus(jobText) 550 | return nil 551 | case tcell.KeyCtrlD: 552 | if len(displayJobs) == 0 { 553 | return nil 554 | } 555 | c := companyList.GetCurrentItem() 556 | target := c + pageScrollAmount 557 | if target < len(displayJobs)-1 { 558 | // page down 559 | companyList.SetCurrentItem(target) 560 | } else { 561 | // select last 562 | companyList.SetCurrentItem(len(displayJobs) - 1) 563 | } 564 | return nil 565 | case tcell.KeyCtrlU: 566 | if len(displayJobs) == 0 { 567 | return nil 568 | } 569 | c := companyList.GetCurrentItem() 570 | target := c - pageScrollAmount 571 | if target > 0 { 572 | // page up 573 | companyList.SetCurrentItem(c - pageScrollAmount) 574 | } else { 575 | // select first 576 | companyList.SetCurrentItem(0) 577 | } 578 | return nil 579 | case tcell.KeyF1: 580 | if !showingModal { 581 | actionHelp() 582 | } 583 | return nil 584 | } 585 | return event 586 | }) 587 | listFrameInner := tview.NewFrame(companyList) 588 | listFrameInner.SetBackgroundColor(listBg) 589 | clFrameTransitionStyle := &sanitview.TViewStyle{ 590 | Fg: curTheme.CompanyList.Colors.FrameHeader.Bg, 591 | Bg: curTheme.CompanyList.Colors.FrameBackground.Bg, 592 | } 593 | listFrame := tview.NewFrame(listFrameInner). 594 | AddText( 595 | fmt.Sprintf( 596 | "%s%s%s%s%s%s", 597 | clFrameTransitionStyle.AsTag(), 598 | "◢", 599 | curTheme.CompanyList.Colors.FrameHeader.AsTag(), 600 | " Company ", 601 | clFrameTransitionStyle.AsTag(), 602 | "◤", 603 | ), 604 | true, tview.AlignCenter, 0, 605 | ). 606 | SetBorders(0, 0, 0, 0, 0, 0) 607 | listFrame. // box attrs 608 | SetBackgroundColor(listBg). 609 | SetBorder(true). 610 | SetBorderColor(focusColor) 611 | 612 | // header 613 | headerText = tview.NewTextView(). // tv attrs 614 | SetDynamicColors(true). 615 | SetScrollable(false) 616 | headerText. //box attrs 617 | SetBackgroundColor(tcell.GetColor(curTheme.UI.HeaderStatsHidden.Bg)) 618 | 619 | // job 620 | jobText = tview.NewTextView(). //tv attrs 621 | SetDynamicColors(true). 622 | SetRegions(true) 623 | jobText. // Box attrs 624 | SetBackgroundColor(tcell.GetColor(curTheme.JobBody.FrameBackground.Bg)) 625 | jobText.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 626 | if sharedRuneHandler(event.Rune()) { 627 | return nil 628 | } 629 | switch event.Key() { 630 | case tcell.KeyTab: 631 | tvApp.SetFocus(companyList) 632 | } 633 | return event 634 | }) 635 | jobFrameInner := tview.NewFrame(jobText) // frame attrs 636 | jobFrameInner.SetBackgroundColor(tcell.GetColor(curTheme.JobBody.FrameBackground.Bg)) 637 | jobFrame = tview.NewFrame(jobFrameInner). 638 | SetBorders(0, 0, 0, 0, 0, 0) 639 | jobFrame. // Box attrs 640 | SetBackgroundColor(tcell.GetColor(curTheme.JobBody.FrameBackground.Bg)). 641 | SetBorder(true). 642 | SetBorderColor(focusColor) 643 | 644 | grid := tview.NewGrid(). 645 | SetRows(1, 0). 646 | SetColumns(maxCompanyNameDisplayLength+11, 0) 647 | grid.AddItem(headerText, 0, 0, 1, 2, 0, 0, false) 648 | grid.AddItem(listFrame, 1, 0, 1, 1, 0, 0, true) 649 | grid.AddItem(jobFrame, 1, 1, 1, 1, 0, 0, false) 650 | 651 | pages = tview.NewPages().AddPage("base", grid, true, true) 652 | 653 | tcellScreen, err = tcell.NewScreen() 654 | if err != nil { 655 | panic(err) 656 | } 657 | tvApp.SetScreen(tcellScreen) 658 | tvApp.SetBeforeDrawFunc(bdf) 659 | tvApp.SetRoot(pages, true) 660 | 661 | if displayOptions.curStory.Id == 0 { 662 | // we couldn't find a latest story in the DB 663 | rebuildHeaderText() 664 | actionConsiderFetch(false) 665 | } else { 666 | loadList(0) 667 | } 668 | 669 | if err = tvApp.Run(); err != nil { 670 | panic(err) 671 | } 672 | } 673 | 674 | func bdf(s tcell.Screen) bool { 675 | screenSize.X, screenSize.Y = tcellScreen.Size() 676 | pageScrollAmount = (screenSize.Y - 6) / 2 677 | rebuildHeaderText() 678 | return false 679 | } 680 | 681 | var inlineStyleBgRegex *regexp.Regexp 682 | 683 | func replaceInlineStyleBgs(s string, bg string) string { 684 | bgLen := len(bg) 685 | if inlineStyleBgRegex == nil { 686 | inlineStyleBgRegex = regexp.MustCompile(`\[.+?:(?P.+?)[:\]]`) 687 | } 688 | offset := 0 689 | matches := inlineStyleBgRegex.FindAllStringSubmatchIndex(s, -1) 690 | for _, m := range matches { 691 | // replace the inline bg style tags 692 | submatchStart := m[2] 693 | submatchEnd := m[3] 694 | submatchLen := m[3] - m[2] 695 | if submatchLen == 0 { 696 | continue 697 | } 698 | diff := bgLen - submatchLen 699 | s = s[:submatchStart+offset] + bg + s[submatchEnd+offset:] 700 | offset += diff 701 | } 702 | return s 703 | } 704 | 705 | func fixItemBg(index int) { 706 | // tview doesn't do cascading styles, so when our list items have inline styles they override the 707 | // "selected item" background color. We then need to manually put the "selected" bg back into those items. 708 | // We choose to leave the Priority jobs with "broken" backgrounds. 709 | if displayJobs[index].Priority { 710 | // we changed the background to priority, just need to redraw it 711 | companyList.SetItemText(index, displayJobs[index].DisplayCompany, "") 712 | } else { 713 | // replace selected item's bg 714 | s := replaceInlineStyleBgs( 715 | displayJobs[index].DisplayCompany, 716 | curTheme.CompanyList.Colors.SelectedItemBackground.Bg, 717 | ) 718 | companyList.SetItemText(index, s, "") 719 | } 720 | } 721 | 722 | func listNavHandler(index int, mainText string, secondaryText string, shortcut rune) { 723 | if companyList.GetItemCount() < len(displayJobs) { 724 | // loadList is currently building the list 725 | return 726 | } 727 | jobText.SetText(displayJobs[index].DisplayText) 728 | jobText.ScrollToBeginning() 729 | 730 | //update "ago" 731 | jobFrame.Clear() 732 | jFrameHeaderTransitionStyle := &sanitview.TViewStyle{ 733 | Fg: curTheme.JobBody.FrameHeader.Bg, 734 | Bg: curTheme.JobBody.FrameBackground.Bg, 735 | } 736 | jobFrame.AddText( 737 | fmt.Sprintf( 738 | "%s%s%s%s%s%s", 739 | jFrameHeaderTransitionStyle.AsTag(), 740 | "◢", 741 | curTheme.JobBody.FrameHeader.AsTag(), 742 | " Job ", 743 | jFrameHeaderTransitionStyle.AsTag(), 744 | "◤", 745 | ), 746 | true, tview.AlignCenter, 0, 747 | ) 748 | 749 | ago := time.Since(displayJobs[index].GoTime) 750 | var agoText string 751 | switch { 752 | case ago < time.Minute: 753 | agoText = " now! " 754 | case ago < time.Hour: 755 | agoText = fmt.Sprintf(" %dm ago ", int(ago.Minutes())) 756 | case ago < 24*time.Hour: 757 | agoText = fmt.Sprintf(" %dh ago ", int(ago.Hours())) 758 | default: 759 | agoText = fmt.Sprintf(" %dd ago ", int(ago.Hours()/24)) 760 | } 761 | frameAgeTransitionStyle := &sanitview.TViewStyle{ 762 | Bg: curTheme.JobBody.Normal.Bg, 763 | Fg: curTheme.UI.HeaderStatsHidden.Bg, 764 | } 765 | jobFrame.AddText( 766 | fmt.Sprintf( 767 | "%s%s%s%s%s%s", 768 | frameAgeTransitionStyle.AsTag(), 769 | "◢", 770 | curTheme.UI.HeaderStatsHidden.AsTag(), 771 | agoText, 772 | frameAgeTransitionStyle.AsTag(), 773 | "◤", 774 | ), 775 | true, tview.AlignRight, 0, 776 | ) 777 | 778 | fixItemBg(index) 779 | if prevSelectedJob != -1 { 780 | // restore the previously-selected item back to unmodified 781 | companyList.SetItemText(prevSelectedJob, displayJobs[prevSelectedJob].DisplayCompany, "") 782 | } 783 | prevSelectedJob = index 784 | } 785 | 786 | // listItemModified is called after we modify an item in the list. It persists the modification and updates the display. 787 | func listItemModified(i int) error { 788 | displayJobs[i].DisplayCompany = formatDisplayCompany(displayJobs[i].Job) 789 | displayJobs[i].Job.ReviewedTime = time.Now().UTC().Unix() 790 | err := db.UpsertJob(displayJobs[i].Job) 791 | maybePanic(err) 792 | fixItemBg(i) 793 | companyList.SetCurrentItem(i) //triggers redraw 794 | rebuildHeaderText() 795 | tvApp.SetFocus(companyList) 796 | return nil 797 | } 798 | 799 | func makeModal(p tview.Primitive, width, height int) tview.Primitive { 800 | return tview.NewFlex(). 801 | AddItem(nil, 0, 1, false). 802 | AddItem( 803 | tview.NewFlex().SetDirection(tview.FlexRow). 804 | AddItem(nil, 0, 1, false). 805 | AddItem(p, height, 1, true). 806 | AddItem(nil, 0, 1, false), 807 | width, 1, true, 808 | ). 809 | AddItem(nil, 0, 1, false) 810 | } 811 | 812 | func showModalTextView(rows int, cols int, text string, title string) { 813 | // I hoped to get more mileage out of this function than I have so far ... 814 | if !showingModal { 815 | showingModal = true 816 | bgColor := tcell.GetColor(curTheme.UI.ModalNormal.Bg) 817 | modalTV := tview.NewTextView(). //TextView attrs 818 | SetSize(rows, cols). 819 | SetDynamicColors(true). 820 | SetTextStyle(curTheme.UI.ModalNormal.AsTCellStyle()) // sets modal bg 821 | modalTV.SetBorder(true). //Box attrs 822 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 823 | SetTitle(curTheme.UI.ModalTitle.AsTag() + title). 824 | SetTitleAlign(tview.AlignLeft). 825 | SetBackgroundColor(bgColor) 826 | modalTV.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 827 | switch event.Key() { 828 | case tcell.KeyEscape: 829 | pages.RemovePage("modalText") 830 | showingModal = false 831 | return nil 832 | } 833 | return event 834 | }) 835 | modalTV.SetText(text) 836 | 837 | pages.AddPage("modalText", makeModal(modalTV, cols, rows+2), true, true) 838 | pages.SetBackgroundColor(bgColor) 839 | 840 | tvApp.SetFocus(modalTV) 841 | } 842 | } 843 | 844 | func actionHelp() { 845 | url := "https://github.com/mwinters0/hnjobs" 846 | hl := curTheme.UI.ModalHighlight.AsTag() 847 | normal := curTheme.UI.ModalNormal.AsTag() 848 | link := sanitview.MergeTviewStyles( 849 | curTheme.UI.ModalHighlight, 850 | &sanitview.TViewStyle{Url: url}, 851 | ).AsTag() 852 | helpText := normal + ` 853 | Bindings: 854 | - Basics 855 | - ` + hl + `ESC` + normal + ` - close dialogs 856 | - ` + hl + `TAB` + normal + ` - switch focus 857 | - ` + hl + `jk` + normal + ` and ` + hl + `up/down` + normal + ` - scroll 858 | - ` + hl + `f` + normal + ` - fetch latest (respecting TTL) 859 | - ` + hl + `F` + normal + ` - force fetch all 860 | - ` + hl + `q` + normal + ` - quit 861 | - Job Filtering 862 | - ` + hl + `r` + normal + ` - mark read / unread 863 | - ` + hl + `x` + normal + ` - mark job uninterested (hidden) or interested (default) 864 | - ` + hl + `p` + normal + ` - mark priority / not priority 865 | - ` + hl + `a` + normal + ` - mark applied to / not applied to 866 | - Display 867 | - ` + hl + `X` + normal + ` - toggle hiding jobs marked uninterested 868 | - ` + hl + `T` + normal + ` - toggle hiding jobs below score threshold 869 | - Misc 870 | - ` + hl + `m` + normal + ` - select month (if multiple in DB) / delete old data 871 | - ` + hl + `s` + normal + ` - reload scoring config and re-score the jobs 872 | 873 | For more info: ` + link + url + normal + ` 874 | ` 875 | showModalTextView(25, 70, helpText, " Help ") 876 | } 877 | 878 | func weHaveData() bool { 879 | if len(displayJobs) == 0 && displayStats.numHidden == 0 { 880 | return false 881 | } 882 | return true 883 | } 884 | 885 | func actionListMarkPriority() { 886 | if len(displayJobs) == 0 { 887 | return 888 | } 889 | i := companyList.GetCurrentItem() 890 | displayJobs[i].Priority = !displayJobs[i].Priority 891 | err := listItemModified(i) 892 | maybePanic(err) 893 | } 894 | 895 | func actionListMarkRead() { 896 | if len(displayJobs) == 0 { 897 | return 898 | } 899 | i := companyList.GetCurrentItem() 900 | displayJobs[i].Read = !displayJobs[i].Read 901 | err := listItemModified(i) 902 | maybePanic(err) 903 | } 904 | 905 | func actionListMarkInterested() { 906 | if len(displayJobs) == 0 { 907 | return 908 | } 909 | i := companyList.GetCurrentItem() 910 | if displayJobs[i].Interested { 911 | displayJobs[i].Interested = false 912 | displayStats.numUninterested++ 913 | displayStats.numUninterestedDisplayed++ 914 | } else { 915 | displayJobs[i].Interested = true 916 | displayStats.numUninterested-- 917 | displayStats.numUninterestedDisplayed-- 918 | } 919 | err := listItemModified(i) 920 | maybePanic(err) 921 | } 922 | 923 | func actionListMarkApplied() { 924 | if len(displayJobs) == 0 { 925 | return 926 | } 927 | i := companyList.GetCurrentItem() 928 | displayJobs[i].Applied = !displayJobs[i].Applied 929 | err := listItemModified(i) 930 | maybePanic(err) 931 | } 932 | 933 | func actionToggleShowBelowThreshold() { 934 | if !weHaveData() { 935 | return 936 | } 937 | displayOptions.showBelowThreshold = !displayOptions.showBelowThreshold 938 | var curSelectedJobId int 939 | if companyList.GetItemCount() > 0 { 940 | curSelectedJobId = displayJobs[companyList.GetCurrentItem()].Id 941 | } 942 | loadList(curSelectedJobId) 943 | } 944 | 945 | func actionToggleShowUninterested() { 946 | if !weHaveData() { 947 | return 948 | } 949 | var curSelectedJobId int 950 | if companyList.GetItemCount() > 0 { 951 | curSelectedJobId = displayJobs[companyList.GetCurrentItem()].Id 952 | } 953 | if displayStats.numUninterestedDisplayed > 0 { 954 | // we have some items marked uninterested but still displayed. Hide them. 955 | loadList(curSelectedJobId) 956 | return 957 | } 958 | displayOptions.showUninterested = !displayOptions.showUninterested 959 | loadList(curSelectedJobId) 960 | } 961 | 962 | func actionConsiderFetch(force bool) { 963 | if displayOptions.curStory.Id == 0 { 964 | // no story loaded, e.g. first launch with empty database 965 | fetchJobs(false) 966 | return 967 | } 968 | fetchJobs(force) 969 | } 970 | 971 | func fetchJobs(force bool) { 972 | showingModal = true 973 | bgColor := tcell.GetColor(curTheme.UI.ModalNormal.Bg) 974 | 975 | // If we find a new story, we want to prompt the user to switch to it 976 | var gotNewStory bool 977 | const suggestSwitchPageName = "suggestSwitch" 978 | suggestSwitchModal := tview.NewModal(). 979 | AddButtons([]string{"Yes", "No"}). 980 | SetDoneFunc(func(buttonIndex int, buttonLabel string) { 981 | switch buttonLabel { 982 | case "Yes": 983 | pages.RemovePage(suggestSwitchPageName) 984 | reset() 985 | setupLatestStory() 986 | loadList(0) 987 | case "No": 988 | pages.RemovePage(suggestSwitchPageName) 989 | tvApp.SetFocus(companyList) 990 | } 991 | }). 992 | SetBackgroundColor(bgColor) 993 | suggestSwitchModal. //box attrs 994 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 995 | SetBorderStyle(curTheme.UI.FocusBorder.AsTCellStyle()) 996 | suggestSwitchModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 997 | switch event.Key() { 998 | case tcell.KeyEscape: 999 | pages.RemovePage(suggestSwitchPageName) 1000 | return nil 1001 | } 1002 | return event 1003 | }) 1004 | 1005 | const fetchModalPageName = "fetchModal" 1006 | ctx, cancelFetch := context.WithCancel(context.Background()) 1007 | done := false 1008 | pDone := &done 1009 | var rows, cols int 1010 | if screenSize.Y > 25 { 1011 | rows = screenSize.Y - 20 1012 | } else { 1013 | rows = 20 1014 | } 1015 | if screenSize.X > 80 { 1016 | cols = screenSize.X - 10 1017 | if cols > 100 { 1018 | cols = 100 1019 | } 1020 | } else { 1021 | cols = 70 1022 | } 1023 | fetchText := tview.NewTextView(). 1024 | SetSize(rows, cols). 1025 | SetDynamicColors(true). 1026 | SetTextStyle(curTheme.UI.ModalNormal.AsTCellStyle()) 1027 | fetchText.SetBorder(true). 1028 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 1029 | SetTitleAlign(tview.AlignLeft). 1030 | SetBackgroundColor(bgColor) 1031 | if force { 1032 | fetchText.SetTitle(curTheme.UI.ModalTitle.AsTag() + " Force Fetching jobs - press ESC to cancel ") 1033 | } else { 1034 | fetchText.SetTitle(curTheme.UI.ModalTitle.AsTag() + " Fetching jobs - press ESC to cancel ") 1035 | } 1036 | _, _ = fetchText.Write([]byte(curTheme.UI.ModalNormal.AsTag())) 1037 | finish := func() { 1038 | *pDone = true 1039 | tvApp.QueueUpdateDraw(func() { 1040 | fetchText.SetTitle(curTheme.UI.ModalTitle.AsTag() + " Done fetching - ESC to close or up/down to review ") 1041 | _, _ = fetchText.Write([]byte(curTheme.UI.ModalNormal.AsTag() + "\n\n Press ESC to close")) 1042 | tvApp.SetFocus(fetchText) 1043 | }) 1044 | } 1045 | fetchText.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 1046 | switch event.Key() { 1047 | case tcell.KeyEscape: 1048 | // first press = stop if WIP 1049 | // 2nd = exit 1050 | if !done { 1051 | cancelFetch() 1052 | finish() 1053 | return nil 1054 | } 1055 | pages.ShowPage("base") 1056 | pages.RemovePage(fetchModalPageName) 1057 | var curJobId int 1058 | if displayOptions.curStory.Id == 0 { 1059 | // if we didn't have a story loaded before, look for one 1060 | setupLatestStory() 1061 | loadList(0) 1062 | } else { 1063 | // reload the current story 1064 | curStory := displayOptions.curStory 1065 | if len(displayJobs) > 0 { 1066 | curJobId = displayJobs[companyList.GetCurrentItem()].Id 1067 | } 1068 | reset() 1069 | displayOptions.curStory = curStory 1070 | loadList(curJobId) 1071 | if gotNewStory { 1072 | // suggest switching to it 1073 | latest, err := db.GetLatestStory() 1074 | maybePanic(err) 1075 | dLatest := newDisplayStory(latest) 1076 | s := fmt.Sprintf( 1077 | "Found a new story!\n\n%s%s%s\n\nSwitch to it?", 1078 | curTheme.UI.ModalHighlight.AsTag(), 1079 | dLatest.DisplayTitle, 1080 | curTheme.UI.ModalNormal.AsTag(), 1081 | ) 1082 | suggestSwitchModal.SetText(s) 1083 | pages.AddPage(suggestSwitchPageName, suggestSwitchModal, true, true) 1084 | tvApp.SetFocus(suggestSwitchModal) 1085 | } 1086 | } 1087 | return nil 1088 | } 1089 | return event 1090 | }) 1091 | addLine := func(line string) { 1092 | if !*pDone { 1093 | _, _ = fetchText.Write([]byte(line + "\n")) 1094 | fetchText.ScrollToEnd() 1095 | } 1096 | } 1097 | 1098 | pages.AddPage(fetchModalPageName, makeModal(fetchText, cols, rows+2), true, true) 1099 | pages.HidePage("base") 1100 | tvApp.SetFocus(fetchText) 1101 | 1102 | updateCallback := func(fsu FetchStatusUpdate) { 1103 | lineStyle := curTheme.JobBody.Normal 1104 | switch fsu.UpdateType { 1105 | case UpdateTypeFatal, UpdateTypeNonFatalErr: 1106 | lineStyle = curTheme.JobBody.NegativeHit 1107 | case UpdateTypeBadComment: 1108 | lineStyle = curTheme.JobBody.Pre 1109 | case UpdateTypeJobFetched: 1110 | if fsu.Value >= displayOptions.threshold { 1111 | lineStyle = curTheme.JobBody.PositiveHit 1112 | } 1113 | case UpdateTypeNewStory: 1114 | gotNewStory = true 1115 | lineStyle = curTheme.JobBody.PositiveHit 1116 | case UpdateTypeDone: 1117 | addLine(" --------------------\n") 1118 | if fsu.Value > 0 { 1119 | // new jobs fetched 1120 | lineStyle = curTheme.UI.HeaderStatsNormal 1121 | } 1122 | } 1123 | tvApp.QueueUpdateDraw(func() { 1124 | addLine(" " + lineStyle.AsTag() + tview.Escape(fsu.Message)) 1125 | }) 1126 | } 1127 | doneCallback := func(err error) { 1128 | finish() 1129 | } 1130 | status := make(chan FetchStatusUpdate) 1131 | fo := FetchOptions{ 1132 | Context: ctx, 1133 | Status: status, 1134 | ModeForce: force, 1135 | StoryID: 0, 1136 | TTLSec: config.GetConfig().Cache.TTLSecs, 1137 | MustContain: WhoIsHiringString, 1138 | } 1139 | go FetchAsync(fo) 1140 | go func() { 1141 | for { 1142 | select { 1143 | case fsu, ok := <-status: 1144 | if !ok { 1145 | //EOF 1146 | doneCallback(nil) 1147 | return 1148 | } 1149 | updateCallback(fsu) 1150 | if fsu.Error != nil { 1151 | if fsu.UpdateType == UpdateTypeFatal { 1152 | doneCallback(fsu.Error) 1153 | return 1154 | } 1155 | } 1156 | } 1157 | } 1158 | }() 1159 | } 1160 | 1161 | func actionBrowseStories() { 1162 | var err error 1163 | var stories []*DisplayStory 1164 | rows := 20 1165 | cols := 60 1166 | textviewRows := 5 1167 | _ = textviewRows 1168 | if !showingModal { 1169 | showingModal = true 1170 | bgColor := tcell.GetColor(curTheme.UI.ModalNormal.Bg) 1171 | storyListItemStyle := curTheme.UI.ModalHighlight 1172 | const browseStoriesPageName = "browseStories" 1173 | 1174 | const deleteConfirmPageName = "storyDeleteConfirm" 1175 | var deleteStory func() // defined later, after storyList 1176 | deleteConfirmModal := tview.NewModal(). 1177 | AddButtons([]string{"Delete", "Cancel"}). 1178 | SetDoneFunc(func(buttonIndex int, buttonLabel string) { 1179 | switch buttonLabel { 1180 | case "Delete": 1181 | deleteStory() 1182 | case "Cancel": 1183 | pages.RemovePage(deleteConfirmPageName) 1184 | } 1185 | }). 1186 | SetBackgroundColor(bgColor) 1187 | deleteConfirmModal. //box attrs 1188 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 1189 | SetBorderStyle(curTheme.UI.FocusBorder.AsTCellStyle()) 1190 | deleteConfirmModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 1191 | switch event.Key() { 1192 | case tcell.KeyEscape: 1193 | pages.RemovePage(deleteConfirmPageName) 1194 | return nil 1195 | } 1196 | return event 1197 | }) 1198 | 1199 | storyList := tview.NewList(). // list attrs 1200 | ShowSecondaryText(false). 1201 | SetWrapAround(false). 1202 | SetSelectedBackgroundColor(tcell.GetColor(curTheme.CompanyList.Colors.SelectedItemBackground.Bg)) 1203 | storyList. // Box attrs 1204 | SetBackgroundColor(bgColor) 1205 | storyList.SetHighlightFullLine(true) 1206 | storyList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 1207 | switch event.Rune() { 1208 | case 'd': 1209 | if storyList.GetItemCount() > 0 { 1210 | i := storyList.GetCurrentItem() 1211 | s := fmt.Sprintf("Delete %s%s%s?", storyListItemStyle.AsTag(), stories[i].DisplayTitle, curTheme.UI.ModalNormal.AsTag()) 1212 | deleteConfirmModal.SetText(s) 1213 | pages.AddPage(deleteConfirmPageName, makeModal(deleteConfirmModal, 30, 5), true, true) 1214 | tvApp.SetFocus(deleteConfirmModal) 1215 | } 1216 | return nil 1217 | case 'g': 1218 | storyList.SetCurrentItem(storyList.GetItemCount()) 1219 | return nil 1220 | case 'G': 1221 | storyList.SetCurrentItem(0) 1222 | return nil 1223 | case 'j': 1224 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 1225 | case 'k': 1226 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 1227 | } 1228 | switch event.Key() { 1229 | case tcell.KeyEscape: 1230 | pages.RemovePage(browseStoriesPageName) 1231 | showingModal = false 1232 | return nil 1233 | } 1234 | return event 1235 | }) 1236 | getStoryTitle := func(s *DisplayStory, storyStyle *sanitview.TViewStyle) string { 1237 | return fmt.Sprintf("%s[ %v ]", storyStyle.AsTag(), s.DisplayTitle) //strip "Ask HN: " 1238 | } 1239 | prevSelectedStory := -1 1240 | storyListNavHandler := func(index int, mainText string, secondaryText string, shortcut rune) { 1241 | if len(stories) == 0 { 1242 | return 1243 | } 1244 | // fix selected bgs 1245 | s := replaceInlineStyleBgs( 1246 | getStoryTitle(stories[index], storyListItemStyle), 1247 | curTheme.CompanyList.Colors.SelectedItemBackground.Bg, 1248 | ) 1249 | storyList.SetItemText(index, s, "") 1250 | if prevSelectedStory != -1 { 1251 | storyList.SetItemText(prevSelectedStory, getStoryTitle(stories[prevSelectedStory], storyListItemStyle), "") 1252 | } 1253 | prevSelectedStory = index 1254 | } 1255 | storyList.SetChangedFunc(storyListNavHandler) 1256 | storyList.SetSelectedFunc(func(i int, s string, s2 string, r rune) { 1257 | pages.RemovePage(browseStoriesPageName) 1258 | if stories[i].Id == displayOptions.curStory.Id { 1259 | return 1260 | } 1261 | reset() 1262 | displayOptions.curStory = stories[i] 1263 | loadList(0) 1264 | }) 1265 | curStoryIndex := -1 1266 | loadStoryList := func() { 1267 | // called here and after delete 1268 | dbStories, err := db.GetAllStories() 1269 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 1270 | tvApp.Suspend(func() { 1271 | panic(err) 1272 | }) 1273 | } 1274 | storyList.Clear() 1275 | stories = []*DisplayStory{} 1276 | for i, story := range dbStories { 1277 | displayStory := newDisplayStory(story) 1278 | stories = append(stories, displayStory) 1279 | if story.Id == displayOptions.curStory.Id { 1280 | curStoryIndex = i 1281 | } 1282 | storyList.AddItem(getStoryTitle(displayStory, storyListItemStyle), "", 0, nil) 1283 | } 1284 | } 1285 | loadStoryList() 1286 | storyListFrameInner := tview.NewFrame(storyList) //frame attrs 1287 | storyListFrameInner.SetBackgroundColor(bgColor) 1288 | storyListFrame := tview.NewFrame(storyListFrameInner). //frame attrs 1289 | AddText( 1290 | " enter to select, d to delete ", 1291 | true, tview.AlignCenter, 0, 1292 | ). 1293 | SetBorders(1, 0, 0, 0, 0, 0) 1294 | storyListFrame. // box attrs 1295 | SetBorder(true). 1296 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 1297 | SetBackgroundColor(bgColor). 1298 | SetTitle(curTheme.UI.ModalTitle.AsTag() + " Select Month "). 1299 | SetTitleAlign(tview.AlignLeft) 1300 | 1301 | storyGrid := tview.NewGrid(). //grid attrs 1302 | SetRows(rows). 1303 | SetColumns(cols) 1304 | storyGrid.AddItem(storyListFrame, 0, 0, 1, 1, 0, 0, true) 1305 | 1306 | deleteStory = func() { 1307 | i := storyList.GetCurrentItem() 1308 | storyId := stories[i].Id 1309 | prevSelectedStory = -1 1310 | err = db.DeleteStoryAndJobsByStoryID(storyId) 1311 | maybePanic(err) 1312 | if storyId == displayOptions.curStory.Id { 1313 | reset() 1314 | // todo? try to auto-load a different story 1315 | } 1316 | loadStoryList() 1317 | pages.RemovePage(deleteConfirmPageName) 1318 | } 1319 | 1320 | pages.AddPage(browseStoriesPageName, makeModal(storyGrid, cols, rows), true, true) 1321 | 1322 | if curStoryIndex != -1 { 1323 | storyList.SetCurrentItem(curStoryIndex) 1324 | } else { 1325 | storyList.SetCurrentItem(0) 1326 | listNavHandler(0, "", "", 0) 1327 | } 1328 | tvApp.SetFocus(storyList) 1329 | } 1330 | } 1331 | 1332 | func actionRescore() { 1333 | if !weHaveData() { 1334 | return 1335 | } 1336 | var err error 1337 | rows := 5 1338 | cols := 50 1339 | const pageName string = "rescoreText" 1340 | if !showingModal { 1341 | showingModal = true 1342 | bgColor := tcell.GetColor(curTheme.UI.ModalNormal.Bg) 1343 | modalTV := tview.NewTextView(). //TextView attrs 1344 | SetSize(rows, cols). 1345 | SetDynamicColors(true). 1346 | SetTextStyle(curTheme.UI.ModalNormal.AsTCellStyle()) // sets modal bg 1347 | modalTV.SetBorder(true). //Box attrs 1348 | SetBorderColor(tcell.GetColor(curTheme.UI.FocusBorder.Fg)). 1349 | SetTitle(curTheme.UI.ModalTitle.AsTag() + " Rescore "). 1350 | SetTitleAlign(tview.AlignLeft). 1351 | SetBackgroundColor(bgColor) 1352 | modalTV.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 1353 | switch event.Key() { 1354 | case tcell.KeyEscape: 1355 | pages.RemovePage(pageName) 1356 | showingModal = false 1357 | return nil 1358 | } 1359 | return event 1360 | }) 1361 | 1362 | modalTV.SetText(fmt.Sprintf("\n Rescoring \"%s\" ...\n", displayOptions.curStory.DisplayTitle)) 1363 | 1364 | pages.AddPage(pageName, makeModal(modalTV, cols, rows+2), true, true) 1365 | tvApp.SetFocus(modalTV) 1366 | 1367 | err = config.Reload() 1368 | maybePanic(err) 1369 | err = scoring.ReloadRules() 1370 | maybePanic(err) 1371 | num, err := ReScore(displayOptions.curStory.Id) 1372 | maybePanic(err) 1373 | _, err = modalTV.Write([]byte(fmt.Sprintf(" Rescored %d jobs. Press ESC to continue.", num))) 1374 | maybePanic(err) 1375 | story := displayOptions.curStory 1376 | var curSelectedJobId int 1377 | if companyList.GetItemCount() > 0 { 1378 | curSelectedJobId = displayJobs[companyList.GetCurrentItem()].Id 1379 | } 1380 | reset() 1381 | displayOptions.curStory = story 1382 | loadList(curSelectedJobId) 1383 | } 1384 | 1385 | } 1386 | 1387 | // loadList (re)loads dataset from DB based on displayOptions. Assumes you've already fetched and set curStoryID. 1388 | // We try to re-select prevDisplayJobID after reload, but it may have become invisible. 1389 | func loadList(prevDisplayJobID int) { 1390 | jobs, err := db.GetAllJobsByStoryId(displayOptions.curStory.Id, db.OrderScoreDesc) 1391 | maybePanic(err) 1392 | if len(jobs) == 0 { 1393 | maybePanic(fmt.Errorf("found zero jobs in the db for curStoryID %d", displayOptions.curStory.Id)) 1394 | } 1395 | displayStats.numTotal = len(jobs) 1396 | 1397 | companyList.Clear() 1398 | displayJobs = []*DisplayJob{} 1399 | displayStats.numBelowThreshold = 0 1400 | displayStats.numUninterested = 0 1401 | displayStats.numHidden = 0 1402 | // rebuild list and try to find previously-selected item by id 1403 | newDJIndex := -1 1404 | for _, job := range jobs { 1405 | dj := newDisplayJob(job) 1406 | if dj.Score < displayOptions.threshold { 1407 | displayStats.numBelowThreshold++ 1408 | } 1409 | if !dj.Interested { 1410 | displayStats.numUninterested++ 1411 | } 1412 | if dj.Hidden { 1413 | displayStats.numHidden++ 1414 | continue 1415 | } 1416 | if prevDisplayJobID == dj.Id { 1417 | // we found our previously-selected item! 1418 | newDJIndex = len(displayJobs) 1419 | } 1420 | displayJobs = append(displayJobs, dj) 1421 | } 1422 | for _, dj := range displayJobs { 1423 | // We have to take two passes, where pass 1 is "build displayJobs" and pass 2 is 1424 | // "add them to the list", because tview is quite buggy (or rather, has buggy 1425 | // opinions IMO) in how it handles the listNavHandler() callback. 1426 | companyList.AddItem(dj.DisplayCompany, "", 0, nil) 1427 | } 1428 | 1429 | prevSelectedJob = -1 // global version 1430 | if newDJIndex != -1 { 1431 | // We found the DJ to select in the list. Simply do that. 1432 | companyList.SetCurrentItem(newDJIndex) 1433 | if newDJIndex == 0 { 1434 | // tview doesn't fire the handler if the selected item is 0 1435 | listNavHandler(0, "", "", 0) 1436 | } 1437 | rebuildHeaderText() 1438 | return 1439 | } 1440 | if len(displayJobs) > 0 { 1441 | companyList.SetCurrentItem(0) 1442 | // tview doesn't fire the handler if the selected item is 0 1443 | listNavHandler(0, "", "", 0) 1444 | } 1445 | rebuildHeaderText() 1446 | } 1447 | 1448 | func rebuildHeaderText() { 1449 | headerText.SetText(getStatsText(screenSize.X)) 1450 | } 1451 | 1452 | func getStatsText(availableWidth int) string { 1453 | if !weHaveData() { 1454 | return "" 1455 | } 1456 | condensedThreshold := 110 // TODO take two passes so we don't have to rely on a guesstimate 1457 | condensed := availableWidth < condensedThreshold 1458 | 1459 | builder := strings.Builder{} 1460 | builder.WriteString(curTheme.UI.HeaderStatsDate.AsTag()) 1461 | numJobsDisplayed := displayStats.numTotal - displayStats.numHidden 1462 | if condensed { 1463 | builder.WriteString(tview.Escape(fmt.Sprintf(" %s ", 1464 | getShortStoryTime(displayOptions.curStory), 1465 | ))) 1466 | builder.WriteString(fmt.Sprintf( 1467 | "%s J:%d ", 1468 | curTheme.UI.HeaderStatsNormal.AsTag(), 1469 | numJobsDisplayed, 1470 | )) 1471 | if displayStats.numHidden > 0 { 1472 | builder.WriteString(fmt.Sprintf( 1473 | "(%dT) ", 1474 | displayStats.numTotal, 1475 | )) 1476 | } 1477 | builder.WriteString(curTheme.UI.HeaderStatsHidden.AsTag()) 1478 | } else { 1479 | transitionStyleDateNormal := &sanitview.TViewStyle{ 1480 | Fg: curTheme.UI.HeaderStatsDate.Bg, 1481 | Bg: curTheme.UI.HeaderStatsNormal.Bg, 1482 | } 1483 | builder.WriteString(tview.Escape(fmt.Sprintf(" %s %s🭬", 1484 | displayOptions.curStory.DisplayTitle, 1485 | transitionStyleDateNormal.AsTag(), 1486 | ))) 1487 | builder.WriteString(fmt.Sprintf( 1488 | "%s Jobs: %d ", 1489 | curTheme.UI.HeaderStatsNormal.AsTag(), 1490 | numJobsDisplayed, 1491 | )) 1492 | if displayStats.numHidden > 0 { 1493 | builder.WriteString(fmt.Sprintf( 1494 | "(%d Total) ", 1495 | displayStats.numTotal, 1496 | )) 1497 | } 1498 | transitionStyleNormalHidden := &sanitview.TViewStyle{ 1499 | Fg: curTheme.UI.HeaderStatsNormal.Bg, 1500 | Bg: curTheme.UI.HeaderStatsHidden.Bg, 1501 | } 1502 | builder.WriteString(tview.Escape(fmt.Sprintf("%s🭬%s", 1503 | transitionStyleNormalHidden.AsTag(), 1504 | curTheme.UI.HeaderStatsHidden.AsTag(), 1505 | ))) 1506 | } 1507 | 1508 | if displayStats.numTotal == 0 || displayStats.numHidden == 0 { 1509 | return builder.String() 1510 | } 1511 | 1512 | // details 1513 | 1514 | var btLabel, uLabel string 1515 | if condensed { 1516 | btLabel = " 0 && !displayOptions.showBelowThreshold { 1525 | btText := fmt.Sprintf("%d%s[%d]", 1526 | displayStats.numBelowThreshold, 1527 | btLabel, 1528 | displayOptions.threshold, 1529 | ) 1530 | moreStats = append(moreStats, btText) 1531 | } 1532 | if displayStats.numUninterested > 0 && !displayOptions.showUninterested { 1533 | uText := fmt.Sprintf("%d%s", 1534 | displayStats.numUninterested, 1535 | uLabel, 1536 | ) 1537 | moreStats = append(moreStats, uText) 1538 | } 1539 | if condensed { 1540 | builder.WriteString(strings.Join(moreStats, ",")) 1541 | } else { 1542 | builder.WriteString(strings.Join(moreStats, ", ")) 1543 | } 1544 | builder.WriteString(")") 1545 | 1546 | return builder.String() 1547 | } 1548 | 1549 | func getShortStoryTime(s *DisplayStory) string { 1550 | monthName := "" 1551 | switch displayOptions.curStory.GoTime.Month() { 1552 | case 1: 1553 | monthName = "Jan." 1554 | case 2: 1555 | monthName = "Feb." 1556 | case 3: 1557 | monthName = "Mar." 1558 | case 4: 1559 | monthName = "Apr." 1560 | case 5: 1561 | monthName = "May" 1562 | case 6: 1563 | monthName = "June" 1564 | case 7: 1565 | monthName = "Jul." 1566 | case 8: 1567 | monthName = "Aug." 1568 | case 9: 1569 | monthName = "Sep." 1570 | case 10: 1571 | monthName = "Oct." 1572 | case 11: 1573 | monthName = "Nov." 1574 | case 12: 1575 | monthName = "Dec." 1576 | default: 1577 | monthName = "uhhh" 1578 | } 1579 | return fmt.Sprintf("%s %s", 1580 | monthName, 1581 | strconv.Itoa(s.GoTime.Year()), 1582 | ) 1583 | } 1584 | --------------------------------------------------------------------------------