├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── add.go ├── cd.go ├── cleanup.go ├── remove.go ├── reset.go ├── root.go └── shell.go ├── go.mod ├── go.sum ├── logo.svg ├── main.go └── pkg ├── database └── config.go ├── entry ├── entry.go ├── entry_sort.go └── frecency.go ├── file └── flock.go └── path ├── lcp.go └── lcp_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@master 14 | - name: goreleaser 15 | uses: docker://goreleaser/goreleaser 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} 18 | with: 19 | args: release 20 | if: success() 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | 5 | test: 6 | name: Test on go ${{ matrix.go_version }} and ${{ matrix.os }} 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | go_version: [ '1.12.x', '1.13.x' ] 11 | os: [ubuntu-latest, macOS-latest] 12 | 13 | steps: 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: ${{ matrix.go_version }} 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v1 22 | 23 | - name: test 24 | run: go test -v ./... --race 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 15 | .glide/ 16 | 17 | # Dep 18 | vendor/ 19 | 20 | .idea/ 21 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: compass 3 | goos: 4 | - darwin 5 | - linux 6 | goarch: 7 | - amd64 8 | 9 | brew: 10 | name: compass 11 | 12 | github: 13 | owner: khoi 14 | name: homebrew-tap 15 | 16 | folder: Formula 17 | 18 | test: | 19 | system "#{bin}/program -h" 20 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" 6 | name = "github.com/inconshreveable/mousetrap" 7 | packages = ["."] 8 | pruneopts = "" 9 | revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" 10 | version = "v1.0" 11 | 12 | [[projects]] 13 | digest = "1:2208a80fc3259291e43b30f42f844d18f4218036dff510f42c653ec9890d460a" 14 | name = "github.com/spf13/cobra" 15 | packages = ["."] 16 | pruneopts = "" 17 | revision = "7b2c5ac9fc04fc5efafb60700713d4fa609b777b" 18 | version = "v0.0.1" 19 | 20 | [[projects]] 21 | digest = "1:261bc565833ef4f02121450d74eb88d5ae4bd74bfe5d0e862cddb8550ec35000" 22 | name = "github.com/spf13/pflag" 23 | packages = ["."] 24 | pruneopts = "" 25 | revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" 26 | version = "v1.0.0" 27 | 28 | [solve-meta] 29 | analyzer-name = "dep" 30 | analyzer-version = 1 31 | input-imports = ["github.com/spf13/cobra"] 32 | solver-name = "gps-cdcl" 33 | solver-version = 1 34 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | [[constraint]] 2 | name = "github.com/spf13/cobra" 3 | version = "0.0.1" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 khoiracle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKGS := $(shell go list ./... | grep -v /vendor) 2 | 3 | .PHONY: test install 4 | 5 | all: test install 6 | 7 | test: 8 | go test -v $(PKGS) 9 | 10 | install: 11 | go install 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test](https://github.com/khoi/compass/workflows/test/badge.svg)](https://github.com/khoi/compass/actions) 2 | [![Release](https://github.com/khoi/compass/workflows/release/badge.svg)](https://github.com/khoi/compass/actions) 3 | [![@khoiracle](https://img.shields.io/badge/contact-@khoiracle-4bbee3.svg?style=flat)](https://twitter.com/khoiracle) 4 | 5 | # Compass 6 | Compass learns your habit, and help navigate to your "frecently used" directory. 7 | 8 | ## Usage 9 | By default, `s` is the key-binding wrapper around `compass`. 10 | 11 | - Fuzzily navigate to directory contains `go` and `compass` : 12 | 13 | ```bash 14 | s compass 15 | # ~/Workspace/go/src/github.com/khoi/compass 16 | ``` 17 | 18 | - For more option refer to: 19 | 20 | ```bash 21 | compass --help 22 | ``` 23 | 24 | ## Install 25 | 26 | Use Homebrew: 27 | 28 | ```bash 29 | brew install khoi/tap/compass 30 | ``` 31 | 32 | For development build: 33 | 34 | ```bash 35 | go get github.com/khoi/compass 36 | ``` 37 | 38 | Add this to the end of your `.zshrc` or `.bash_profile` 39 | 40 | ```bash 41 | eval "$(compass shell)" 42 | ``` 43 | 44 | For fish shell add the line below to your `~/.config/fish/config.fish` 45 | 46 | ```bash 47 | if type -q compass 48 | status --is-interactive; and source (compass shell --type fish -|psub) 49 | end 50 | ``` 51 | 52 | If you want to use different key binding pass `--bind-to` to the `compass shell` command: 53 | 54 | For instance, if you want to use `z` instead of `s` 55 | 56 | ```bash 57 | eval "$(compass shell --bind-to z)" 58 | ``` 59 | 60 | ## References 61 | 62 | - [rupa/z](https://github.com/rupa/z) 63 | - [wting/autojump](https://github.com/wting/autojump) 64 | - [gsamokovarov/jump](https://github.com/gsamokovarov/jump) 65 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "sort" 7 | "time" 8 | 9 | "github.com/khoi/compass/pkg/entry" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // addCmd represents the add command 14 | var addCmd = &cobra.Command{ 15 | Use: "add", 16 | Short: "Add a folder to the database", 17 | Run: addRun, 18 | } 19 | 20 | func addRun(cmd *cobra.Command, args []string) { 21 | if len(args) == 0 { 22 | exit(errors.New("Missing folder.")) 23 | } 24 | 25 | path, err := filepath.Abs(args[0]) 26 | 27 | if err != nil { 28 | exit(err) 29 | } 30 | 31 | entries, err := fileDb.Read() 32 | 33 | if err != nil { 34 | exit(err) 35 | } 36 | 37 | sort.Sort(entry.ByPath(entries)) 38 | idx := sort.Search(len(entries), func(i int) bool { 39 | return entries[i].Path >= path 40 | }) 41 | 42 | if idx < len(entries) && entries[idx].Path == path { // Entry exists, update the score 43 | entries[idx].VisitedCount += 1 44 | entries[idx].LastVisited = int(time.Now().Unix()) 45 | } else { // Create a new entry 46 | entries = append(entries, nil) 47 | copy(entries[idx+1:], entries[idx:]) 48 | entries[idx] = &entry.Entry{ 49 | Path: path, 50 | VisitedCount: 1, 51 | LastVisited: int(time.Now().Unix()), 52 | } 53 | } 54 | 55 | if err := fileDb.Write(entries); err != nil { 56 | exit(err) 57 | } 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(addCmd) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/cd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "errors" 9 | 10 | "github.com/khoi/compass/pkg/entry" 11 | "github.com/khoi/compass/pkg/path" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var cdCmd = &cobra.Command{ 16 | Use: "cd", 17 | Short: "Print the top match for search terms", 18 | Run: cdRun, 19 | } 20 | 21 | func cdRun(cmd *cobra.Command, args []string) { 22 | var queries []string 23 | 24 | if len(args) > 0 { 25 | queries = args[0:] 26 | } 27 | 28 | entries, err := fileDb.Read() 29 | 30 | if err != nil { 31 | exit(err) 32 | } 33 | 34 | var matchedEntries = entry.Entries(entries) 35 | 36 | for _, query := range queries { 37 | matchedEntries = matchedEntries.Filter(func(e *entry.Entry) bool { 38 | return strings.Contains(e.Path, query) || strings.Contains(strings.ToLower(e.Path), strings.ToLower(query)) 39 | }) 40 | } 41 | 42 | var filtered []*entry.Entry 43 | var filteredPaths []string 44 | 45 | for _, e := range matchedEntries { 46 | filtered = append(filtered, e) 47 | filteredPaths = append(filteredPaths, e.Path) 48 | } 49 | 50 | sort.Sort(entry.ByFrecency(filtered)) 51 | 52 | for _, e := range filtered { 53 | filteredPaths = append(filteredPaths, e.Path) 54 | } 55 | 56 | if len(filteredPaths) == 0 { 57 | exit(errors.New("")) 58 | } 59 | 60 | output := filteredPaths[len(filteredPaths)-1] 61 | if common := path.LCP(filteredPaths); common != "" { 62 | for _, p := range filteredPaths { 63 | if p == common { 64 | output = common 65 | break 66 | } 67 | } 68 | } 69 | 70 | fmt.Printf("%s\n", output) 71 | } 72 | 73 | func init() { 74 | rootCmd.AddCommand(cdCmd) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/cleanup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/khoi/compass/pkg/entry" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // cleanupCmd represents the cleanup command 12 | var cleanupCmd = &cobra.Command{ 13 | Use: "cleanup", 14 | Short: "Remove non-existing folder from the database", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | if verbose { 17 | fmt.Println("compass cleaning up.") 18 | } 19 | 20 | entries, err := fileDb.Read() 21 | 22 | if err != nil { 23 | exit(err) 24 | } 25 | 26 | var valid []*entry.Entry 27 | 28 | for _, e := range entries { 29 | if _, err := os.Stat(e.Path); err == nil { 30 | valid = append(valid, e) 31 | continue 32 | } 33 | if verbose { 34 | fmt.Fprintf(os.Stdout, "Removed %s\n", e.Path) 35 | } 36 | } 37 | 38 | if err := fileDb.Write(valid); err != nil { 39 | exit(err) 40 | } 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(cleanupCmd) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/remove.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/khoi/compass/pkg/entry" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var removeCmd = &cobra.Command{ 13 | Use: "remove", 14 | Short: "Remove a folder from the database", 15 | Run: removeRun, 16 | } 17 | 18 | func removeRun(cmd *cobra.Command, args []string) { 19 | if len(args) == 0 { 20 | exit(errors.New("Missing folder.")) 21 | } 22 | 23 | path, err := filepath.Abs(args[0]) 24 | 25 | if err != nil { 26 | exit(err) 27 | } 28 | 29 | entries, err := fileDb.Read() 30 | 31 | if err != nil { 32 | exit(err) 33 | } 34 | 35 | newEntries := entry.Entries(entries).Filter(func(e *entry.Entry) bool { 36 | return e.Path != path 37 | }) 38 | 39 | if err := fileDb.Write(newEntries); err != nil { 40 | exit(err) 41 | } 42 | 43 | fmt.Printf("%s removed.\n", path) 44 | } 45 | 46 | func init() { 47 | rootCmd.AddCommand(removeCmd) 48 | } 49 | -------------------------------------------------------------------------------- /cmd/reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var purgeCmd = &cobra.Command{ 9 | Use: "purge", 10 | Short: "Purge the database.", 11 | Run: func(cmd *cobra.Command, args []string) { 12 | fmt.Printf("%s purged.\n", cfgFile) 13 | fileDb.Truncate() 14 | }, 15 | } 16 | 17 | func init() { 18 | rootCmd.AddCommand(purgeCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "os/user" 8 | "path/filepath" 9 | 10 | "github.com/khoi/compass/pkg/database" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var defaultConfigFileName = ".compass" 15 | var cfgFile string 16 | var verbose bool 17 | var fileDb database.DB 18 | 19 | // rootCmd represents the base command when called without any subcommands 20 | var rootCmd = &cobra.Command{ 21 | Use: "compass", 22 | Short: "Compass, navigate around your pirate 🛳", 23 | } 24 | 25 | // Execute adds all child commands to the root command and sets flags appropriately. 26 | // This is called by main.main(). It only needs to happen once to the rootCmd. 27 | func Execute() { 28 | if err := rootCmd.Execute(); err != nil { 29 | exit(err) 30 | } 31 | } 32 | 33 | func init() { 34 | cobra.OnInitialize(initDB) 35 | rootCmd.PersistentFlags().StringVarP(&cfgFile, "file", "f", "", "path to the db file (default is $HOME/.compass)") 36 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") 37 | } 38 | 39 | func initDB() { 40 | var err error 41 | 42 | if cfgFile == "" { 43 | usr, err := user.Current() 44 | if err != nil { 45 | exit(err) 46 | } 47 | cfgFile = filepath.Join(usr.HomeDir, defaultConfigFileName) 48 | } 49 | 50 | if fileDb, err = database.New(cfgFile); err != nil { 51 | exit(err) 52 | } 53 | } 54 | 55 | func exit(err error) { 56 | fmt.Println(err) 57 | os.Exit(1) 58 | } 59 | -------------------------------------------------------------------------------- /cmd/shell.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "html/template" 7 | 8 | "bytes" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | const sh = `#!bin/sh 14 | if [ -n "${BASH}" ]; then 15 | shell="bash" 16 | elif [ -n "${ZSH_NAME}" ]; then 17 | shell="zsh" 18 | elif [ -n "${__fish_datadir}" ]; then 19 | shell="fish" 20 | else 21 | shell=$(echo "${SHELL}" | awk -F/ '{ print $NF }') 22 | fi 23 | if [ "${shell}" = "sh" ]; then 24 | return 0 25 | fi 26 | eval "$(compass shell --type "$shell" --bind-to {{.Binding}})" 27 | ` 28 | 29 | const zsh = `__compass_chpwd() { 30 | [[ "$(pwd)" == "$HOME" ]] && return 31 | (compass add "$(pwd)" &) 32 | } 33 | [[ -n "${precmd_functions[(r)__compass_chpwd]}" ]] || { 34 | precmd_functions[$(($#precmd_functions+1))]=__compass_chpwd 35 | } 36 | {{.Binding}}() { 37 | local output="$(compass cd $@)" 38 | if [ -d "$output" ]; then 39 | builtin cd "$output" 40 | else 41 | compass cleanup && false 42 | fi 43 | } 44 | __compass_completion() { 45 | reply=(${(f)"$(compass ls --path-only "$1")"}) 46 | } 47 | compctl -U -K __compass_completion {{.Binding}} 48 | ` 49 | 50 | const bash = `__compass_chpwd() { 51 | [[ "$(pwd)" == "$HOME" ]] && return 52 | (compass add "$(pwd)" &) 53 | } 54 | grep "compass add" <<< "$PROMPT_COMMAND" >/dev/null || { 55 | PROMPT_COMMAND="$PROMPT_COMMAND"$'\n''(__compass_chpwd 2>/dev/null &);' 56 | } 57 | {{.Binding}}() { 58 | local output="$(compass cd $@)" 59 | if [ -d "$output" ]; then 60 | builtin cd "$output" 61 | else 62 | compass cleanup && false 63 | fi 64 | } 65 | complete -o dirnames -C 'compass ls --path-only "${COMP_LINE/#{{.Binding}} /}"' {{.Binding}} 66 | ` 67 | 68 | const fish = `function {{.Binding}} 69 | set -l output (compass cd $argv) 70 | if test -d "$output" 71 | cd $output 72 | else 73 | compass cleanup; false 74 | end 75 | end 76 | 77 | function __compass_add --on-variable PWD 78 | status --is-command-substitution; and return 79 | if contains -- (pwd) $HOME 80 | return 81 | end 82 | compass add (pwd) 83 | end 84 | 85 | complete -c {{.Binding}} -x -a '(compass ls --path-only (commandline -t))' 86 | ` 87 | 88 | func scriptForShell(shell string, keyBinding string) string { 89 | var b struct { 90 | Binding string 91 | } 92 | b.Binding = keyBinding 93 | shellType := func() string { 94 | switch shell { 95 | case "bash": 96 | return bash 97 | case "zsh": 98 | return zsh 99 | case "fish": 100 | return fish 101 | default: 102 | return sh 103 | } 104 | }() 105 | 106 | tmpl, err := template.New("compass").Parse(shellType) 107 | 108 | if err != nil { 109 | panic(err) 110 | } 111 | 112 | var buf bytes.Buffer 113 | err = tmpl.Execute(&buf, b) 114 | 115 | if err != nil { 116 | panic(err) 117 | } 118 | 119 | return buf.String() 120 | } 121 | 122 | var shellType string 123 | var keyBinding string 124 | 125 | var shellCmd = &cobra.Command{ 126 | Use: "shell", 127 | Short: "Prints out the shell integration scripts.", 128 | Run: func(cmd *cobra.Command, args []string) { 129 | fmt.Printf(scriptForShell(shellType, keyBinding)) 130 | }, 131 | } 132 | 133 | func init() { 134 | rootCmd.AddCommand(shellCmd) 135 | shellCmd.Flags().StringVarP(&shellType, "type", "t", "sh", "Type of the shell (bash|zsh|fish)") 136 | shellCmd.Flags().StringVarP(&keyBinding, "bind-to", "b", "s", "Key binding (default is `s`)") 137 | } 138 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/khoi/compass 2 | 3 | require ( 4 | github.com/inconshreveable/mousetrap v1.0.0 5 | github.com/spf13/cobra v0.0.1 6 | github.com/spf13/pflag v1.0.0 7 | ) 8 | 9 | go 1.13 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 2 | github.com/spf13/cobra v0.0.1 h1:zZh3X5aZbdnoj+4XkaBxKfhO4ot82icYdhhREIAXIj8= 3 | github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 4 | github.com/spf13/pflag v1.0.0 h1:oaPbdDe/x0UncahuwiPxW1GYJyilRAdsPnq3e1yaPcI= 5 | github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 6 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2017 khoiracle 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import "github.com/khoi/compass/cmd" 18 | 19 | func main() { 20 | cmd.Execute() 21 | } 22 | -------------------------------------------------------------------------------- /pkg/database/config.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "encoding/csv" 5 | "strconv" 6 | 7 | "github.com/khoi/compass/pkg/entry" 8 | "github.com/khoi/compass/pkg/file" 9 | ) 10 | 11 | type DB interface { 12 | Read() ([]*entry.Entry, error) 13 | Write([]*entry.Entry) error 14 | Truncate() error 15 | } 16 | 17 | type fileDb struct { 18 | dbPath string 19 | } 20 | 21 | func (f *fileDb) Write(entries []*entry.Entry) error { 22 | flock := file.NewFlock(f.dbPath) 23 | if err := flock.Lock(); err != nil { 24 | return err 25 | } 26 | defer flock.Unlock() 27 | 28 | err := flock.File().Truncate(0) 29 | _, err = flock.File().Seek(0, 0) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | w := csv.NewWriter(flock.File()) 35 | defer w.Flush() 36 | 37 | for _, e := range entries { 38 | data := []string{e.Path, strconv.Itoa(e.VisitedCount), strconv.Itoa(e.LastVisited)} 39 | if err := w.Write(data); err != nil { 40 | return err 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func (f *fileDb) Read() ([]*entry.Entry, error) { 48 | flock := file.NewFlock(f.dbPath) 49 | if err := flock.Lock(); err != nil { 50 | return nil, err 51 | } 52 | defer flock.Unlock() 53 | 54 | r := csv.NewReader(flock.File()) 55 | records, err := r.ReadAll() 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | entries := make([]*entry.Entry, 0, len(records)) 62 | for _, r := range records { 63 | visitedCount, err := strconv.Atoi(r[1]) 64 | lastVisited, err := strconv.Atoi(r[2]) 65 | 66 | if err != nil { 67 | continue 68 | } 69 | 70 | entries = append(entries, &entry.Entry{ 71 | Path: r[0], 72 | VisitedCount: visitedCount, 73 | LastVisited: lastVisited, 74 | }) 75 | } 76 | 77 | return entries, nil 78 | } 79 | 80 | func (f *fileDb) Truncate() error { 81 | flock := file.NewFlock(f.dbPath) 82 | if err := flock.Lock(); err != nil { 83 | return err 84 | } 85 | defer flock.Unlock() 86 | flock.File().Truncate(0) 87 | return flock.File().Sync() 88 | } 89 | 90 | func New(filePath string) (DB, error) { 91 | return &fileDb{ 92 | dbPath: filePath, 93 | }, nil 94 | } 95 | -------------------------------------------------------------------------------- /pkg/entry/entry.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | type Entry struct { 4 | Path string 5 | VisitedCount int 6 | LastVisited int 7 | } 8 | 9 | type Entries []*Entry 10 | 11 | func (e Entries) Map(f func(*Entry) interface{}) Entries { 12 | result := make(Entries, len(e)) 13 | for _, v := range e { 14 | result = append(result, v) 15 | } 16 | return result 17 | } 18 | 19 | func (e Entries) Filter(f func(*Entry) bool) Entries { 20 | result := make(Entries, 0) 21 | for _, v := range e { 22 | if f(v) { 23 | result = append(result, v) 24 | } 25 | } 26 | return result 27 | } 28 | -------------------------------------------------------------------------------- /pkg/entry/entry_sort.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | type ByPath []*Entry 4 | 5 | func (e ByPath) Len() int { 6 | return len(e) 7 | } 8 | 9 | func (e ByPath) Less(i, j int) bool { 10 | return e[i].Path < e[j].Path 11 | } 12 | 13 | func (e ByPath) Swap(i, j int) { 14 | e[i], e[j] = e[j], e[i] 15 | } 16 | 17 | type ByFrecency []*Entry 18 | 19 | func (e ByFrecency) Len() int { 20 | return len(e) 21 | } 22 | 23 | func (e ByFrecency) Less(i, j int) bool { 24 | return Frecency(e[i]) < Frecency(e[j]) 25 | } 26 | 27 | func (e ByFrecency) Swap(i, j int) { 28 | e[i], e[j] = e[j], e[i] 29 | } 30 | -------------------------------------------------------------------------------- /pkg/entry/frecency.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Based off https://github.com/rupa/z/wiki/frecency 8 | func Frecency(e *Entry) float64 { 9 | dx := int(time.Now().Unix()) - e.LastVisited 10 | if dx < 3600 { 11 | return float64(e.VisitedCount) * 4 12 | } 13 | if dx < 86400 { 14 | return float64(e.VisitedCount) * 2 15 | } 16 | if dx < 604800 { 17 | return float64(e.VisitedCount) / 2 18 | } 19 | return float64(e.VisitedCount) / 4 20 | } 21 | -------------------------------------------------------------------------------- /pkg/file/flock.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | type Flock struct { 9 | path string 10 | file *os.File 11 | } 12 | 13 | func NewFlock(path string) *Flock { 14 | return &Flock{path: path} 15 | } 16 | 17 | func (f *Flock) File() *os.File { 18 | return f.file 19 | } 20 | 21 | func (f *Flock) Lock() error { 22 | if f.file == nil { 23 | if err := f.createOrOpenFile(); err != nil { 24 | return err 25 | } 26 | } 27 | 28 | if err := syscall.Flock(int(f.file.Fd()), syscall.LOCK_EX); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func (f *Flock) Unlock() error { 36 | if err := syscall.Flock(int(f.file.Fd()), syscall.LOCK_UN); err != nil { 37 | return err 38 | } 39 | return f.file.Close() 40 | } 41 | 42 | func (f *Flock) createOrOpenFile() error { 43 | file, err := os.OpenFile(f.path, os.O_CREATE|os.O_RDWR, os.FileMode(0644)) 44 | if err != nil { 45 | return err 46 | } 47 | f.file = file 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/path/lcp.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | // LCP returns the longest common path for the list of path, 9 | // or an empty string if no prefix is found. 10 | func LCP(l []string) string { 11 | if len(l) == 0 { 12 | return "" 13 | } 14 | 15 | if len(l) == 1 { 16 | return path.Clean(l[0]) 17 | } 18 | 19 | min := path.Clean(l[0]) 20 | max := min 21 | 22 | for _, p := range l[1:] { 23 | p = path.Clean(p) 24 | 25 | switch { 26 | case p < min: 27 | min = p 28 | case p > max: 29 | max = p 30 | } 31 | } 32 | 33 | result := append([]byte(min), os.PathSeparator) 34 | for i := 0; i < len(result) && i < len(max); i++ { 35 | if result[i] != max[i] { 36 | result = result[:i] 37 | break 38 | } 39 | } 40 | 41 | for i := len(result) - 1; i >= 1; i-- { 42 | if result[i] == os.PathSeparator { 43 | result = result[:i] 44 | break 45 | } 46 | } 47 | 48 | return string(result) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/path/lcp_test.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var testTable = []struct { 8 | in []string 9 | out string 10 | }{ 11 | {[]string{}, ""}, 12 | {[]string{"foo"}, "foo"}, 13 | {[]string{"/foo/bar/.."}, "/foo"}, 14 | {[]string{"foo", "bar"}, ""}, 15 | {[]string{"home/khoiracle", "home/khoiracle/foo", "home/khoiracle/bar"}, "home/khoiracle"}, 16 | {[]string{"home/khoiracle/bar/..", "home/khoiracle/foo"}, "home/khoiracle"}, 17 | {[]string{"/abc/bcd/cdf", "/abc/bcd/cdf/foo", "/abc/bcd/chi/hij", "/abc/bcd/cdd"}, "/abc/bcd"}, 18 | {[]string{"./abc/bcd/cdf", "./abc/bcd/cdf/foo", "./abc/bcd/chi/hij", "./abc/bcd/cdd"}, "abc/bcd"}, 19 | {[]string{"/abc/bcd/cdf", "/"}, "/"}, 20 | {[]string{"/abc/def/ghj", "/abc/def"}, "/abc/def"}, 21 | {[]string{"Github/khoi/ios", "Github/khoi/webcontent-ios", "Github/khoi/ios/iosNetworking"}, "Github/khoi"}, 22 | } 23 | 24 | func TestLCP(t *testing.T) { 25 | for _, c := range testTable { 26 | if out := LCP(c.in); out != c.out { 27 | t.Errorf("Expected %s - Got %s", c.out, out) 28 | } 29 | } 30 | } 31 | --------------------------------------------------------------------------------