diff --git a/README.md b/README.md index 4986ed8..73a9b1c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,44 @@ # Browser tab groups +Save and open **links** from the command line with ease. A tap group is a collection of links (urls) that belong together. +e.g `work` tap group would contain links for work. +e.g `uni` tap group would contain links for uni etc. + ## Features 1. Group `urls` with a label + - use case: for work i want to quickly open `[Gitlab, Jira, Github, ...]` + - use case: for a given issue i want to quickly open its `[jira link, bitbucket pr, bitbucket branch, ...]` + - use case: for uni i want to quickly open `[Moodle, Web mailer, ...]` 1. Open a group of `urls` from the cli in the browser 1. Open a single `url` from a group of `urls` + - use case: for a given issue i saved its urls `[Bitbucket, Jira, Github, ...]` but want to quickly open only its `Jira link` without the rest of urls because i don't need them right now. 1. Remove a group of `urls` + - use case: after being done with a ticket. i want to remove all of its saved links ## Usage -1. `bt list` to list all saved tab groups -1. `bt add ` to add the `url` to the tap group `tap group` -1. `bt open ` to open all `urls` in the tap group `tap group` in the browser -1. `bt open ` to open the url(s) that _fuzzy match_ `url matching string` in the browser +1. `br` will print the usage +1. `br list` to list all saved tab groups +1. `br add ` to add the `url` to the tap group `tap group` +1. `br open ` to open all `urls` in the tap group `tap group` in the browser +1. `br open ` to open the url(s) that _fuzzy match_ `url matching string` in the browser + +## Workflow looks like this + +1. `br add work https://enerxess.atlassian.net/jira/your-work` +1. `br add work https://bitbucket.org/exseffi` +1. `br add uni https://webmail.tu-dortmund.de/roundcubemail/` +1. `br ls` + + ``` + uni: + https://webmail.tu-dortmund.de/roundcubemail/ + + work: + https://enerxess.atlassian.net/jira/your-work + https://bitbucket.org/exseffi + ``` + +1. `br open work` would open the two links under the `work` group in the browser +1. `br open work bit` would open the link for **bitbucket** because it uses `fuzzy finding` to filter for links based on the user's input diff --git a/browser/browser.go b/browser/browser.go new file mode 100644 index 0000000..10a75f6 --- /dev/null +++ b/browser/browser.go @@ -0,0 +1,51 @@ +package browser + +import ( + "os/exec" + "runtime" +) + +type Browser interface { + //OpenLink opens a link in the browser + OpenLink(link string) error + + //OpenLinks opens a link in the browser + OpenLinks(links []string) error +} + +// browser is the internal implementation for the Browser interface +type browser struct { +} + +// OpenLink opens a link in the browser +func (br *browser) OpenLink(link string) error { + + var args []string + switch runtime.GOOS { + case "darwin": + args = []string{"open"} + case "windows": + args = []string{"cmd", "/c", "start"} + default: + args = []string{"xdg-open"} + } + cmd := exec.Command(args[0], append(args[1:], link)...) + return cmd.Start() +} + +// OpenLinks opens all links in the browser +func (br *browser) OpenLinks(links []string) error { + + for _, link := range links { + err := br.OpenLink(link) + if err != nil { + return err + } + + } + return nil +} + +func NewBrowser() Browser { + return &browser{} +} diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..df7bc0e --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "io" + "os" + + "github.com/magdyamr542/browser-tab-groups/configManager" +) + +// Adding a new tap group +type AddCmd struct { + TapGroup string `arg:"" name:"tap group" help:"the tap group to add the url to"` + Url string `arg:"" name:"url" help:"the url to add"` +} + +func (add *AddCmd) Run() error { + jsonCmg, err := configManager.NewJsonConfigManager() + if err != nil { + return err + } + return addUrlToTapGroup(os.Stdout, jsonCmg, add.TapGroup, add.Url) +} + +// addUrlToTapGroup adds the given url to the given tap group +func addUrlToTapGroup(outputW io.Writer, cm configManager.ConfigManager, tapGroup, url string) error { + err := cm.AddUrl(url, tapGroup) + if err != nil { + return err + } + outputW.Write([]byte("url added")) + return nil +} diff --git a/cmd/ls.go b/cmd/ls.go new file mode 100644 index 0000000..bc5290d --- /dev/null +++ b/cmd/ls.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/magdyamr542/browser-tab-groups/configManager" +) + +// Listing all tap groups with their urls +type LsCmd struct{} + +func (ls *LsCmd) Run() error { + + jsonCmg, err := configManager.NewJsonConfigManager() + if err != nil { + return err + } + return listTapGroups(os.Stdout, jsonCmg) +} + +// listTapGroups lists all tap groups +func listTapGroups(outputW io.Writer, cm configManager.ConfigManager) error { + cfg, err := cm.GetConfig() + if err != nil { + return err + } + + i := 0 + for groupName, urls := range cfg { + entry := fmt.Sprintf("%v:\n", groupName) + outputW.Write([]byte(entry)) + for i := range urls { + outputW.Write([]byte(fmt.Sprintf(" %v\n", urls[i]))) + } + i += 1 + if i < len(cfg) { + outputW.Write([]byte("\n")) + } + } + return nil +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..35549dc --- /dev/null +++ b/cmd/open.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "errors" + "io" + "os" + "strings" + + "github.com/magdyamr542/browser-tab-groups/browser" + "github.com/magdyamr542/browser-tab-groups/configManager" + "github.com/magdyamr542/browser-tab-groups/helpers" + + "github.com/lithammer/fuzzysearch/fuzzy" +) + +// Adding a new tap group +type OpenCmd struct { + TapGroup string `arg:"" name:"tap group" help:"the tap group to add the url to"` + UrlLike string `arg:"" optional:"" name:"url part" help:"a part of the url to be use with fuzzy matching"` +} + +func (open *OpenCmd) Run() error { + jsonCmg, err := configManager.NewJsonConfigManager() + if err != nil { + return err + } + return openTapGroup(os.Stdout, open.TapGroup, open.UrlLike, jsonCmg, browser.NewBrowser()) +} + +// AddUrlToTapGroup adds the given url to the given tap group +func openTapGroup(outputW io.Writer, tapGroup string, urlLike string, cm configManager.ConfigManager, br browser.Browser) error { + urls, err := cm.GetUrls(tapGroup) + if err != nil { + return err + } + if len(urls) == 0 { + return errors.New("the given tap group does not have urls") + } + + if len(strings.TrimSpace(urlLike)) > 0 { + urlLikeLower := strings.ToLower(urlLike) + urls = helpers.Filter(urls, func(url string) bool { + return fuzzy.Match(urlLikeLower, strings.ToLower(url)) + }) + + if len(urls) == 0 { + return errors.New("no matches found in the given tap group") + } + } + + return br.OpenLinks(urls) +} diff --git a/cmd/rm.go b/cmd/rm.go new file mode 100644 index 0000000..78ff9bf --- /dev/null +++ b/cmd/rm.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "io" + "os" + + "github.com/magdyamr542/browser-tab-groups/configManager" +) + +// Removing a tap group +type RmCmd struct { + TapGroup string `arg:"" name:"tap group" help:"the tap group to remove"` +} + +func (rm *RmCmd) Run() error { + + jsonCmg, err := configManager.NewJsonConfigManager() + if err != nil { + return err + } + return removeTapGroup(os.Stdout, jsonCmg, rm.TapGroup) +} + +// removeTapGroup removes a saved tap group +func removeTapGroup(outputW io.Writer, cm configManager.ConfigManager, tapGroup string) error { + err := cm.RemoveTapGroup(tapGroup) + if err != nil { + return err + } + outputW.Write([]byte("removed")) + return nil +} diff --git a/configManager/configManager.go b/configManager/configManager.go new file mode 100644 index 0000000..3b71bb3 --- /dev/null +++ b/configManager/configManager.go @@ -0,0 +1,15 @@ +package configManager + +type ConfigManager interface { + //GetConfig gets the config instance. this should be a map from a tap group to the list of urls + GetConfig() (map[string][]string, error) + + // AddUrl adds the given url to the given tap group. the tap group is created if it does not exist + AddUrl(url string, tapGroup string) error + + // GetUrls gets the urls for the given tap group + GetUrls(tapGroup string) ([]string, error) + + // RemoveTapGroup removes all urls saved in the given tap group + RemoveTapGroup(tapGroup string) error +} diff --git a/configManager/jsonConfigManager.go b/configManager/jsonConfigManager.go new file mode 100644 index 0000000..1a49b51 --- /dev/null +++ b/configManager/jsonConfigManager.go @@ -0,0 +1,136 @@ +package configManager + +import ( + "encoding/json" + "errors" + "os" + "path" + "path/filepath" + "strings" + + "github.com/magdyamr542/browser-tab-groups/helpers" +) + +// jsonConfigManager is an internal implementation for the config manager that saves the data as a json file +type jsonConfigManager struct { + dirPath string + fileName string + homeDir string +} + +var errGroupDoesNotExist error = errors.New("the group does not exist") +var errInputIsNotUrl error = errors.New("the given input is not a url") +var errUrlIsAlreadyInTapGroup error = errors.New("the url is already in the tap group") + +func (cm *jsonConfigManager) GetConfig() (map[string][]string, error) { + byteValue, _ := os.ReadFile(cm.storagePath()) + if len(byteValue) == 0 { + byteValue = append(byteValue, []byte("{}")...) + } + var result map[string][]string + err := json.Unmarshal([]byte(byteValue), &result) + if err != nil { + return map[string][]string{}, err + } + return result, nil +} + +func (cm *jsonConfigManager) GetUrls(tapGroup string) ([]string, error) { + db, err := cm.GetConfig() + if err != nil { + return []string{}, err + } + + trimmedTapGroup := strings.TrimSpace(tapGroup) + _, ok := db[trimmedTapGroup] + if !ok { + return []string{}, errGroupDoesNotExist + } + + return db[trimmedTapGroup], nil +} + +func (cm *jsonConfigManager) AddUrl(url, tapGroup string) error { + db, err := cm.GetConfig() + if err != nil { + return err + } + _, ok := db[tapGroup] + if !ok { + db[tapGroup] = []string{} + } + + // Validate + trimmedUrl := strings.TrimSpace(url) + if !helpers.IsUrl(strings.TrimSpace(trimmedUrl)) { + return errInputIsNotUrl + } + if helpers.Contains(db[tapGroup], strings.TrimSpace(trimmedUrl)) { + return errUrlIsAlreadyInTapGroup + } + + db[tapGroup] = append(db[tapGroup], strings.TrimSpace(trimmedUrl)) + return cm.refreshStorage(db) +} + +func (cm *jsonConfigManager) RemoveTapGroup(tapGroup string) error { + db, err := cm.GetConfig() + if err != nil { + return err + } + trimmedTapGroup := strings.TrimSpace(tapGroup) + _, ok := db[trimmedTapGroup] + if !ok { + return errGroupDoesNotExist + } + delete(db, trimmedTapGroup) + return cm.refreshStorage(db) +} + +// overrides the saved file +func (cm *jsonConfigManager) refreshStorage(data map[string][]string) error { + storagePath := cm.storagePath() + flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC + f, err := os.OpenFile(storagePath, flags, 0600) + if err != nil { + return err + } + defer f.Close() + + file, _ := json.MarshalIndent(data, "", " ") + + return os.WriteFile(storagePath, file, 0600) + +} + +// create dir and file for storage +func (cm *jsonConfigManager) initStorage() error { + + err := os.MkdirAll(path.Join(cm.homeDir, cm.dirPath), os.ModePerm) + if err != nil { + return err + } + _, err = os.OpenFile(cm.storagePath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + + return nil +} + +func (cm *jsonConfigManager) storagePath() string { + return filepath.Join(cm.homeDir, cm.dirPath, cm.fileName) +} + +func NewJsonConfigManager() (ConfigManager, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + cm := jsonConfigManager{dirPath: ".browser-tabs", fileName: "tabs.json", homeDir: homeDir} + err = cm.initStorage() + if err != nil { + return nil, err + } + return &cm, nil +} diff --git a/go.mod b/go.mod index 53d3233..0cafc0a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ -module browser-tabs +module github.com/magdyamr542/browser-tab-groups go 1.17 + +require ( + github.com/alecthomas/kong v0.7.0 + github.com/lithammer/fuzzysearch v1.1.5 +) + +require golang.org/x/text v0.3.7 // indirect diff --git a/go.sum b/go.sum index e69de29..9ee59d3 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= +github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= +github.com/alecthomas/kong v0.7.0 h1:YIjJUiR7AcmHxL87UlbPn0gyIGwl4+nYND0OQ4ojP7k= +github.com/alecthomas/kong v0.7.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= +github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= +github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/helpers/contains.go b/helpers/contains.go new file mode 100644 index 0000000..5e55226 --- /dev/null +++ b/helpers/contains.go @@ -0,0 +1,11 @@ +package helpers + +// Contains checks if a slice s contains a string +func Contains(s []string, str string) bool { + for _, a := range s { + if a == str { + return true + } + } + return false +} diff --git a/helpers/filter.go b/helpers/filter.go new file mode 100644 index 0000000..46c4a5e --- /dev/null +++ b/helpers/filter.go @@ -0,0 +1,11 @@ +package helpers + +// Filter filters an array of strings given a predicate +func Filter(array []string, predicate func(string) bool) (ret []string) { + for _, s := range array { + if predicate(s) { + ret = append(ret, s) + } + } + return +} diff --git a/helpers/isUrl.go b/helpers/isUrl.go new file mode 100644 index 0000000..1b7c35f --- /dev/null +++ b/helpers/isUrl.go @@ -0,0 +1,10 @@ +package helpers + +import "net/url" + +// IsUrl checks if the given string s is a url +func IsUrl(s string) bool { + _, err := url.ParseRequestURI(s) + return err == nil + +} diff --git a/main.go b/main.go index 5f53158..9119df7 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,27 @@ package main import ( - "fmt" + "os" + + "github.com/magdyamr542/browser-tab-groups/cmd" + + "github.com/alecthomas/kong" ) +var CLI struct { + Ls cmd.LsCmd `cmd:"" help:"List all tab groups" aliases:"l,ls,list"` + Add cmd.AddCmd `cmd:"" help:"Add a new url to a tap group"` + Open cmd.OpenCmd `cmd:"" help:"Open a tap group in the browser"` + Rm cmd.RmCmd `cmd:"" help:"Remove a saved tap group"` +} + func main() { - fmt.Println("Tab groups") + // If running without any extra arguments, default to the --help flag + if len(os.Args) < 2 { + os.Args = append(os.Args, "--help") + } + ctx := kong.Parse(&CLI) + err := ctx.Run() + ctx.FatalIfErrorf(err) }