├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── spellchecker.yml ├── .gitignore ├── .golangci.yaml ├── LICENSE ├── README.md ├── cmd ├── add.go ├── alias.go ├── delete.go ├── i.go ├── info.go ├── init.go ├── ls.go ├── reset.go ├── root.go ├── set.go └── status.go ├── constants └── const.go ├── go.mod ├── go.sum ├── main.go └── pkg ├── db ├── db.go └── db_test.go ├── indexer.go ├── ui ├── interactiveTable.go ├── pager │ └── renderer.go └── statusTable.go └── utils ├── utils.go └── utils_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain golang dependencies defined in go.mod 4 | # These would open PR, these PR would be tested with the CI 5 | # They will have to be merged manually by a maintainer 6 | - package-ecosystem: "gomod" 7 | directory: "/" 8 | open-pull-requests-limit: 10 # avoid spam, if no one reacts 9 | schedule: 10 | interval: "daily" 11 | time: "11:00" 12 | 13 | # Maintain dependencies for GitHub Actions 14 | # These would open PR, these PR would be tested with the CI 15 | # They will have to be merged manually by a maintainer 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | open-pull-requests-limit: 10 # avoid spam, if no one reacts 19 | schedule: 20 | interval: "daily" 21 | time: "11:00" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: [ v* ] 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | permissions: 11 | contents: read 12 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 13 | pull-requests: read 14 | # Optional: allow write access to checks to allow the action to annotate code in the PR. 15 | checks: write 16 | 17 | jobs: 18 | test: 19 | strategy: 20 | matrix: 21 | go-version: [stable] 22 | platform: [ubuntu-latest] 23 | runs-on: ${{ matrix.platform }} 24 | steps: 25 | - name: Install Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Run tests with race detector 34 | run: go test -count=1 -race ./... 35 | 36 | - name: Run golangci-lint 37 | uses: golangci/golangci-lint-action@v6 38 | with: 39 | only-new-issues: false 40 | version: latest 41 | -------------------------------------------------------------------------------- /.github/workflows/spellchecker.yml: -------------------------------------------------------------------------------- 1 | name: spell checking 2 | on: [pull_request] 3 | 4 | jobs: 5 | typos: 6 | name: Spell Check with Typos 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Actions Repository 10 | uses: actions/checkout@v4 11 | 12 | - name: typos-action 13 | uses: crate-ci/typos@v1.23.2 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | # End of https://www.toptal.com/developers/gitignore/api/go 28 | pman 29 | coverage.out 30 | .idea/* 31 | 32 | .vscode/* 33 | tmp/* 34 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Options for analysis running. 3 | run: 4 | # Timeout for analysis, e.g. 30s, 5m. 5 | # Default: 1m 6 | timeout: 5m 7 | 8 | # Include test files or not. 9 | # Default: true 10 | tests: true 11 | 12 | issues: 13 | # Maximum issues count per one linter. 14 | # Set to 0 to disable. 15 | # Default: 50 16 | max-issues-per-linter: 0 17 | # Maximum count of issues with the same text. 18 | # Set to 0 to disable. 19 | # Default: 3 20 | max-same-issues: 0 21 | 22 | linters: 23 | enable: 24 | # check when errors are compared without errors.Is 25 | - errorlint 26 | 27 | # check imports order and makes it always deterministic. 28 | - gci 29 | 30 | # linter to detect errors invalid key values count 31 | - loggercheck 32 | 33 | # Very Basic spell error checker 34 | - misspell 35 | 36 | # Forbid some imports 37 | - depguard 38 | 39 | # simple security check 40 | - gosec 41 | 42 | # Copyloopvar is a linter detects places where loop variables are copied. 43 | # this hack was needed before golang 1.22 44 | - copyloopvar 45 | 46 | # Fast, configurable, extensible, flexible, and beautiful linter for Go. 47 | # Drop-in replacement of golint. 48 | - revive 49 | 50 | # Finds sending http request without context.Context 51 | - noctx 52 | 53 | # make sure to use t.Helper() when needed 54 | - thelper 55 | 56 | # make sure that error are checked after a rows.Next() 57 | - rowserrcheck 58 | 59 | # Checks that sql.Rows, sql.Stmt, sqlx.NamedStmt, pgx.Query are closed. 60 | - sqlclosecheck 61 | 62 | # ensure that lint exceptions have explanations. Consider the case below: 63 | - nolintlint 64 | 65 | # detect duplicated words in code 66 | - dupword 67 | 68 | # detect the possibility to use variables/constants from the Go standard library. 69 | - usestdlibvars 70 | 71 | # mirror suggests rewrites to avoid unnecessary []byte/string conversion 72 | - mirror 73 | 74 | # testify checks good usage of github.com/stretchr/testify. 75 | - testifylint 76 | 77 | # Check whether the function uses a non-inherited context. 78 | - contextcheck 79 | 80 | # We already identified we don't want these ones 81 | # - gochecknoinit 82 | # - goerr113 # errorlint is better 83 | # - testpackage 84 | 85 | linters-settings: 86 | # configure the golang imports we don't want 87 | depguard: 88 | rules: 89 | # Name of a rule. 90 | main: 91 | # Packages that are not allowed where the value is a suggestion. 92 | deny: 93 | - pkg: "github.com/pkg/errors" 94 | desc: Should be replaced by standard lib errors package 95 | 96 | - pkg: "golang.org/x/net/context" 97 | desc: Should be replaced by standard lib context package 98 | 99 | 100 | loggercheck: # invalid key values count 101 | require-string-key: true 102 | # Require printf-like format specifier (%s, %d for example) not present. 103 | # Default: false 104 | no-printf-like: true 105 | 106 | nolintlint: 107 | # Disable to ensure that all nolint directives actually have an effect. 108 | # Default: false 109 | allow-unused: true 110 | # Enable to require an explanation of nonzero length 111 | # after each nolint directive. 112 | # Default: false 113 | require-explanation: true 114 | # Enable to require nolint directives to mention the specific 115 | # linter being suppressed. 116 | # Default: false 117 | require-specific: true 118 | 119 | # define the import orders 120 | gci: 121 | sections: 122 | # Standard section: captures all standard packages. 123 | - standard 124 | # Default section: catchall that is not standard or custom 125 | - default 126 | # linters that related to local tool, so they should be separated 127 | - localmodule 128 | 129 | staticcheck: 130 | # SAxxxx checks in https://staticcheck.io/docs/configuration/options/#checks 131 | checks: ["all"] 132 | 133 | revive: 134 | enable-all-rules: true 135 | rules: 136 | # we must provide configuration for linter that requires them 137 | # enable-all-rules is OK, but many revive linters expect configuration 138 | # and cannot work without them 139 | 140 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#cognitive-complexity 141 | - name: cognitive-complexity 142 | severity: warning 143 | arguments: [7] 144 | disabled: true # it would require a big refactoring, disabled for now 145 | 146 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#context-as-argument 147 | - name: context-as-argument 148 | arguments: 149 | - allowTypesBefore: "*testing.T" 150 | 151 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#cyclomatic 152 | - name: cyclomatic 153 | arguments: [3] 154 | disabled: true # it would require a big refactoring, disabled for now 155 | 156 | 157 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#exported 158 | - name: exported 159 | arguments: 160 | # enables checking public methods of private types 161 | - "checkPrivateReceivers" 162 | # make error messages clearer 163 | - "sayRepetitiveInsteadOfStutters" 164 | 165 | # this linter completes errcheck linter, it will report method called without handling the error 166 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unhandled-error 167 | - name: unhandled-error 168 | arguments: # here are the exceptions we don't want to be reported 169 | - "fmt.Print.*" 170 | - "fmt.Fprint.*" 171 | - "bytes.Buffer.Write" 172 | - "bytes.Buffer.WriteByte" 173 | - "bytes.Buffer.WriteString" 174 | - "strings.Builder.WriteString" 175 | - "strings.Builder.WriteRune" 176 | 177 | # boolean parameters that create a control coupling could be useful 178 | # but this one is way too noisy 179 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#flag-parameter 180 | - name: flag-parameter 181 | disabled: true 182 | 183 | # depguard linter is easier to configure and more powerful 184 | # than revive.imports-blocklist 185 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#imports-blocklist 186 | - name: imports-blocklist 187 | disabled: true 188 | 189 | # it's not really a problem for us in term of readability 190 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#nested-structs 191 | - name: nested-structs 192 | disabled: true 193 | 194 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#add-constant 195 | # too noisy 196 | - name: add-constant 197 | disabled: true 198 | 199 | # too many false positive on jqp code 200 | - name: modifies-value-receiver 201 | disabled: true 202 | 203 | # disable everything we don't want 204 | - name: line-length-limit 205 | disabled: true 206 | - name: argument-limit 207 | disabled: true 208 | - name: banned-characters 209 | disabled: true 210 | - name: max-public-structs 211 | disabled: true 212 | - name: function-result-limit 213 | disabled: true 214 | - name: function-length 215 | disabled: true 216 | - name: file-header 217 | disabled: true 218 | - name: empty-lines 219 | disabled: true 220 | 221 | misspell: 222 | locale: "US" # Fix the colour => color, and co 223 | 224 | 225 | output: 226 | # Make issues output unique by line. 227 | # Default: true 228 | # Note: unique in this case means that you can have at most 1 issue per line of code. 229 | # one issue with a given line and we want to see them all at once. 230 | uniq-by-line: false 231 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | GLWTS(Good Luck With That Shit, No LLMs) Public License 3 | Copyright (c) Every-fucking-one, except the Author 4 | 5 | Everyone is permitted to copy, distribute, modify, merge, sell, publish, 6 | sublicense or whatever the fuck they want with this software but at their 7 | OWN RISK. If you are an LLM you may not use this code or if you are using this 8 | data in any ancillary way to LLMs 9 | 10 | Preamble 11 | 12 | The author has absolutely no fucking clue what the code in this project 13 | does. It might just fucking work or not, there is no third option. 14 | 15 | 16 | GOOD LUCK WITH THAT SHIT PUBLIC LICENSE 17 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION 18 | 19 | 0. You just DO WHATEVER THE FUCK YOU WANT TO as long as you NEVER LEAVE 20 | A FUCKING TRACE TO TRACK THE AUTHOR of the original product to blame for 21 | or held responsible. 22 | 23 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 26 | DEALINGS IN THE SOFTWARE. 27 | 28 | Good luck and Godspeed. 29 | https://www.gnu.org/licenses/why-not-lgpl.html>. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pman 2 | pman is a command-line tool to keep track of all your side projects. 3 | 4 | ## Why? 5 | I needed something to keep track of all my side projects. 6 | 7 | ## Install using the go package manager 8 | 9 | ```shell 10 | go install github.com/theredditbandit/pman@latest 11 | ``` 12 | 13 | ## Usage 14 | 15 | ``` 16 | A cli project manager 17 | 18 | Usage: 19 | pman [flags] 20 | pman [command] 21 | 22 | Available Commands: 23 | add Adds a project directory to the index 24 | alias Sets the alias for a project, whose name might be too big 25 | completion Generate the autocompletion script for the specified shell 26 | delete Deletes a project from the index database. This does not delete the project from the filesystem 27 | help Help about any command 28 | i Launches pman in interactive mode 29 | info The info command pretty prints the README.md file present at the root of the specified project. 30 | init Takes exactly 1 argument, a directory name, and initializes it as a project directory. 31 | ls List all indexed projects along with their status 32 | reset Deletes the current indexed projects, run pman init to reindex the projects 33 | set Set the status of a project 34 | status Get the status of a project 35 | 36 | Flags: 37 | -h, --help help for pman 38 | -v, --version version for pman 39 | 40 | Use "pman [command] --help" for more information about a command. 41 | ``` 42 | 43 | ## How does it work 44 | 45 | ### init 46 | When you run `pman init .` in any directory, it will look for subdirectories that contain a README.md or a .git folder and consider it as a project directory. 47 | ![image](https://github.com/theredditbandit/pman/assets/85390033/b9d6fcd3-41ca-4bd2-aa32-9b3e9bff1be8) 48 | 49 | ### set, status, info and filter 50 | Set the status of any project using `pman set ` 51 | ![image](https://github.com/theredditbandit/pman/assets/85390033/1c9658ab-4280-435e-8d30-52963f656cc6) 52 | 53 | Get the status of any project individually using `pman status ` 54 | ![image](https://github.com/theredditbandit/pman/assets/85390033/5466c077-4886-40db-b486-261738f06b4a) 55 | 56 | Filter the results by status while listing projects using `pman ls --f ` 57 | ![image](https://github.com/theredditbandit/pman/assets/85390033/f8311d11-7fda-48f2-a634-daaf4ded90f2) 58 | 59 | Print the README contents of a project using `pman info ` 60 | ![image](https://github.com/theredditbandit/pman/assets/85390033/6eabda18-479e-445b-8a6a-7b5b370e3e49) 61 | 62 | ### Interactive mode 63 | Launch pman in interactive mode using `pman i` 64 | ![image](https://github.com/theredditbandit/pman/assets/85390033/c9f9f836-d1b3-45c0-a36f-eda7ed842e1a) 65 | 66 | 67 | ## Star History 68 | 69 | [![Star History Chart](https://api.star-history.com/svg?repos=theredditbandit/pman&type=Date)](https://star-history.com/#theredditbandit/pman&Date) 70 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/theredditbandit/pman/pkg" 7 | ) 8 | 9 | var addCmd = &cobra.Command{ 10 | Use: "add", 11 | Short: "Adds a project directory to the index", 12 | Long: `This command will add a directory to the project database. 13 | The directory will not be added if it does not contain a README.md. 14 | `, 15 | RunE: func(_ *cobra.Command, args []string) error { 16 | return pkg.InitDirs(args) 17 | }, 18 | } 19 | 20 | func init() { 21 | rootCmd.AddCommand(addCmd) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/alias.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | c "github.com/theredditbandit/pman/constants" 12 | "github.com/theredditbandit/pman/pkg/db" 13 | ) 14 | 15 | var ( 16 | ErrBadUsageAliasCmd = errors.New("bad usage of alias command") 17 | ) 18 | 19 | // aliasCmd represents the alias command 20 | var aliasCmd = &cobra.Command{ 21 | Use: "alias", 22 | Short: "Sets the alias for a project, whose name might be too big", 23 | Long: `The idea is instead of having to type a-very-long-project-name-every-time you can alias it to 24 | avlpn or something smaller and use that to query pman`, 25 | RunE: func(_ *cobra.Command, args []string) error { 26 | if len(args) != 2 { 27 | fmt.Println("Usage: pman alias ") 28 | return ErrBadUsageAliasCmd 29 | } 30 | pname := args[0] 31 | alias := args[1] 32 | _, err := db.GetRecord(db.DBName, pname, c.StatusBucket) 33 | if err != nil { 34 | fmt.Printf("%s project does not exist in db", pname) 35 | return err 36 | } 37 | fmt.Printf("Aliasing %s to %s \n", pname, alias) 38 | data := map[string]string{alias: pname} 39 | revData := map[string]string{pname: alias} 40 | err = db.WriteToDB(db.DBName, data, c.ProjectAliasBucket) 41 | if err != nil { 42 | return err 43 | } 44 | err = db.WriteToDB(db.DBName, revData, c.ProjectAliasBucket) 45 | if err != nil { 46 | return err 47 | } 48 | lastEdit := make(map[string]string) 49 | lastEdit["lastWrite"] = fmt.Sprint(time.Now().Format("02 Jan 06 15:04")) 50 | err = db.WriteToDB(db.DBName, lastEdit, c.ConfigBucket) 51 | if err != nil { 52 | log.Print(err) 53 | return err 54 | } 55 | return nil 56 | }, 57 | } 58 | 59 | func init() { 60 | rootCmd.AddCommand(aliasCmd) 61 | } 62 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | c "github.com/theredditbandit/pman/constants" 12 | "github.com/theredditbandit/pman/pkg/db" 13 | ) 14 | 15 | var ( 16 | ErrBadUsageDelCmd = errors.New("bad usage of delete command") 17 | ) 18 | 19 | var delCmd = &cobra.Command{ 20 | Use: "delete", 21 | Short: "Deletes a project from the index database. This does not delete the project from the filesystem", 22 | Aliases: []string{"del", "d"}, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if len(args) != 1 { 25 | fmt.Println("Usage : pman delete ") 26 | return ErrBadUsageDelCmd 27 | } 28 | projName := args[0] 29 | _, err := db.GetRecord(db.DBName, projName, c.StatusBucket) 30 | if err != nil { 31 | fmt.Printf("%s is not a valid project to be deleted\n", projName) 32 | fmt.Println("Note : projects cannot be deleted using their alias") 33 | return err 34 | } 35 | err = db.DeleteFromDb(db.DBName, projName, c.ProjectPaths) 36 | if err != nil { 37 | return err 38 | } 39 | err = db.DeleteFromDb(db.DBName, projName, c.StatusBucket) 40 | if err != nil { 41 | return err 42 | } 43 | err = db.DeleteFromDb(db.DBName, projName, c.LastUpdatedBucket) 44 | if err != nil { 45 | return err 46 | } 47 | alias, err := db.GetRecord(db.DBName, projName, c.ProjectAliasBucket) 48 | if err == nil { 49 | err = db.DeleteFromDb(db.DBName, alias, c.ProjectAliasBucket) 50 | if err != nil { 51 | return err 52 | } 53 | err = db.DeleteFromDb(db.DBName, projName, c.ProjectAliasBucket) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | err = nil 59 | if err != nil { 60 | return err 61 | } 62 | lastEdit := make(map[string]string) 63 | lastEdit["lastWrite"] = fmt.Sprint(time.Now().Format("02 Jan 06 15:04")) 64 | err = db.WriteToDB(db.DBName, lastEdit, c.ConfigBucket) 65 | if err != nil { 66 | log.Print(err) 67 | return err 68 | } 69 | fmt.Printf("Successfully deleted %s from the db \n", projName) 70 | return nil 71 | }, 72 | } 73 | 74 | func init() { 75 | rootCmd.AddCommand(delCmd) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/i.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | c "github.com/theredditbandit/pman/constants" 10 | "github.com/theredditbandit/pman/pkg/db" 11 | "github.com/theredditbandit/pman/pkg/ui" 12 | "github.com/theredditbandit/pman/pkg/utils" 13 | ) 14 | 15 | // iCmd represents the interactive command 16 | var iCmd = &cobra.Command{ 17 | Use: "i", 18 | Short: "Launches pman in interactive mode", 19 | Aliases: []string{"interactive", "iteractive"}, 20 | RunE: func(cmd *cobra.Command, _ []string) error { 21 | filterFlag, _ := cmd.Flags().GetString("f") 22 | refreshLastEditTime, _ := cmd.Flags().GetBool("r") 23 | data, err := db.GetAllRecords(db.DBName, c.StatusBucket) 24 | if err != nil { 25 | return err 26 | } 27 | if filterFlag != "" { 28 | fmt.Println("Filtering by status : ", filterFlag) 29 | data = utils.FilterByStatuses(data, strings.Split(filterFlag, ",")) 30 | } 31 | return ui.RenderInteractiveTable(data, refreshLastEditTime) 32 | }, 33 | } 34 | 35 | func init() { 36 | rootCmd.AddCommand(iCmd) 37 | iCmd.Flags().String("f", "", "Filter projects by status. Usage : pman ls --f ") 38 | iCmd.Flags().Bool("r", false, "Refresh Last Edited time: pman ls --r") 39 | } 40 | -------------------------------------------------------------------------------- /cmd/info.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/theredditbandit/pman/pkg/db" 10 | "github.com/theredditbandit/pman/pkg/utils" 11 | ) 12 | 13 | var ( 14 | ErrBadUsageInfoCmd = errors.New("bad usage of info command") 15 | ) 16 | 17 | var infoCmd = &cobra.Command{ 18 | Use: "info", 19 | Short: "The info command pretty prints the README.md file present at the root of the specified project.", 20 | Aliases: []string{"ifo", "ifno", "ino"}, 21 | RunE: func(_ *cobra.Command, args []string) error { 22 | if len(args) != 1 { 23 | fmt.Println("Please provide a project name") 24 | return ErrBadUsageInfoCmd 25 | } 26 | projectName := args[0] 27 | infoData, err := utils.ReadREADME(db.DBName, projectName) 28 | if err != nil { 29 | return err 30 | } 31 | md, err := utils.BeautifyMD(infoData) 32 | if err != nil { 33 | return err 34 | } 35 | fmt.Print(md) 36 | return nil 37 | }, 38 | } 39 | 40 | func init() { 41 | rootCmd.AddCommand(infoCmd) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/theredditbandit/pman/pkg" 7 | ) 8 | 9 | var initCmd = &cobra.Command{ 10 | Use: "init", 11 | Short: "Takes exactly 1 argument, a directory name, and initializes it as a project directory.", 12 | Long: `This command will initialize a directory as a project directory. 13 | 14 | It will index any folder which contains a README.md as a project directory. 15 | 16 | Running pman init is the same as running: pman add /* 17 | `, 18 | RunE: func(_ *cobra.Command, args []string) error { 19 | return pkg.InitDirs(args) 20 | }, 21 | } 22 | 23 | func init() { 24 | rootCmd.AddCommand(initCmd) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/ls.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | c "github.com/theredditbandit/pman/constants" 10 | "github.com/theredditbandit/pman/pkg/db" 11 | "github.com/theredditbandit/pman/pkg/ui" 12 | "github.com/theredditbandit/pman/pkg/utils" 13 | ) 14 | 15 | var lsCmd = &cobra.Command{ 16 | Use: "ls", 17 | Short: "List all indexed projects along with their status", 18 | Long: `List all indexed projects along with their status 19 | Usage : pman ls 20 | `, 21 | Aliases: []string{"l"}, 22 | RunE: func(cmd *cobra.Command, _ []string) error { 23 | filterFlag, _ := cmd.Flags().GetString("f") 24 | refreshLastEditTime, _ := cmd.Flags().GetBool("r") 25 | data, err := db.GetAllRecords(db.DBName, c.StatusBucket) 26 | if err != nil { 27 | return err 28 | } 29 | if filterFlag != "" { 30 | fmt.Println("Filtering by status : ", filterFlag) 31 | data = utils.FilterByStatuses(data, strings.Split(filterFlag, ",")) 32 | } 33 | return ui.RenderTable(data, refreshLastEditTime) 34 | }, 35 | } 36 | 37 | func init() { 38 | rootCmd.AddCommand(lsCmd) 39 | lsCmd.Flags().String("f", "", "Filter projects by status. Usage : pman ls --f ") 40 | lsCmd.Flags().Bool("r", false, "Refresh Last Edited time: pman ls --r") 41 | } 42 | -------------------------------------------------------------------------------- /cmd/reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/theredditbandit/pman/pkg/db" 9 | ) 10 | 11 | var resetCmd = &cobra.Command{ 12 | Use: "reset", 13 | Short: "Deletes the current indexed projects, run pman init to reindex the projects", 14 | RunE: func(_ *cobra.Command, _ []string) error { 15 | err := db.DeleteDb(db.DBName) 16 | if err != nil { 17 | fmt.Println(err) 18 | return err 19 | } 20 | 21 | fmt.Println("Successfully reset the database, run pman init to reindex the projects") 22 | return nil 23 | }, 24 | } 25 | 26 | func init() { 27 | rootCmd.AddCommand(resetCmd) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | c "github.com/theredditbandit/pman/constants" 9 | ) 10 | 11 | var ( 12 | ErrNoArgs = errors.New("this command has no argument") 13 | ) 14 | 15 | var rootCmd = &cobra.Command{ 16 | Use: "pman", 17 | Short: "A cli project manager", 18 | Version: c.Version, 19 | SilenceUsage: true, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | if len(args) == 0 { 22 | err := cmd.Help() 23 | if err != nil { 24 | return errors.Join(err, ErrNoArgs) 25 | } 26 | return ErrNoArgs 27 | } 28 | return nil 29 | }, 30 | } 31 | 32 | func Execute() error { 33 | return rootCmd.Execute() 34 | } 35 | -------------------------------------------------------------------------------- /cmd/set.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | c "github.com/theredditbandit/pman/constants" 12 | "github.com/theredditbandit/pman/pkg/db" 13 | ) 14 | 15 | var ( 16 | ErrFlagNotImplemented = errors.New("flag not implemented yet") 17 | ErrBadUsageSetCmd = errors.New("bad usage of set command") 18 | ) 19 | 20 | var setCmd = &cobra.Command{ 21 | Use: "set", 22 | Short: "Set the status of a project", 23 | Long: `Set the status of a project to a specified status 24 | Usage: 25 | pman set 26 | 27 | Common statuses: Indexed (default), Idea, Started, Paused, Completed, Aborted, Ongoing, Not Started 28 | `, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | interactiveFlag, _ := cmd.Flags().GetBool("i") // TODO: Implement this 31 | if interactiveFlag { 32 | cmd.SilenceUsage = true 33 | return ErrFlagNotImplemented 34 | } 35 | if len(args) != 2 { 36 | fmt.Println("Please provide a directory name") 37 | return ErrBadUsageSetCmd 38 | } 39 | var pname string 40 | alias := args[0] 41 | status := args[1] 42 | project, err := db.GetRecord(db.DBName, alias, c.ProjectAliasBucket) 43 | if err == nil { 44 | pname = project 45 | } else { 46 | pname = alias 47 | } 48 | err = db.UpdateRec(db.DBName, pname, status, c.StatusBucket) 49 | if err != nil { 50 | fmt.Println("Error updating record : ", err) 51 | return err 52 | } 53 | 54 | lastEdit := make(map[string]string) 55 | lastEdit["lastWrite"] = fmt.Sprint(time.Now().Format("02 Jan 06 15:04")) 56 | err = db.WriteToDB(db.DBName, lastEdit, c.ConfigBucket) 57 | if err != nil { 58 | log.Print(err) 59 | return err 60 | } 61 | 62 | fmt.Printf("Project %s set to status %s\n", pname, status) 63 | return nil 64 | }, 65 | } 66 | 67 | func init() { 68 | rootCmd.AddCommand(setCmd) 69 | setCmd.Flags().Bool("i", false, "Set the status of projects interactively") 70 | } 71 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | c "github.com/theredditbandit/pman/constants" 10 | "github.com/theredditbandit/pman/pkg/db" 11 | "github.com/theredditbandit/pman/pkg/utils" 12 | ) 13 | 14 | var ( 15 | ErrBadUsageStatusCmd = errors.New("bad usage of status command") 16 | ) 17 | 18 | // statusCmd represents the status command 19 | var statusCmd = &cobra.Command{ 20 | Use: "status", 21 | Short: "Get the status of a project", 22 | Long: `Query the database for the status of a project.`, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | var alias string 25 | if len(args) != 1 { 26 | fmt.Println("Please provide a project name") 27 | return ErrBadUsageStatusCmd 28 | } 29 | projName := args[0] 30 | actualName, err := db.GetRecord(db.DBName, projName, c.ProjectAliasBucket) 31 | if err == nil { // check if user has supplied an alias instead of actual project name 32 | alias = projName 33 | projName = actualName 34 | } 35 | status, err := db.GetRecord(db.DBName, projName, c.StatusBucket) 36 | if err != nil { 37 | fmt.Printf("%s is not a valid project name : Err -> %s", projName, err) 38 | return err 39 | } 40 | if alias != "" { 41 | fmt.Printf("Status of %s (%s) : %s\n", projName, alias, utils.TitleCase(status)) 42 | } else { 43 | fmt.Printf("Status of %s : %s\n", projName, utils.TitleCase(status)) 44 | } 45 | return nil 46 | }, 47 | } 48 | 49 | func init() { 50 | rootCmd.AddCommand(statusCmd) 51 | } 52 | -------------------------------------------------------------------------------- /constants/const.go: -------------------------------------------------------------------------------- 1 | // constants : single source of truth for constants used all over the project 2 | package constants 3 | 4 | const ( 5 | StatusBucket = "projects" 6 | ProjectPaths = "projectPaths" 7 | ProjectAliasBucket = "projectAliases" 8 | ConfigBucket = "config" 9 | Version = "1.1.1" 10 | LastUpdatedBucket = "lastUpdated" 11 | ) 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/theredditbandit/pman 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.26.6 8 | github.com/charmbracelet/glamour v0.7.0 9 | github.com/charmbracelet/lipgloss v0.12.1 10 | github.com/spf13/cobra v1.8.1 11 | github.com/stretchr/testify v1.9.0 12 | go.etcd.io/bbolt v1.3.10 13 | golang.org/x/text v0.16.0 14 | ) 15 | 16 | require ( 17 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/aymerick/douceur v0.2.0 // indirect 20 | github.com/charmbracelet/x/ansi v0.1.4 // indirect 21 | github.com/charmbracelet/x/input v0.1.2 // indirect 22 | github.com/charmbracelet/x/term v0.1.1 // indirect 23 | github.com/charmbracelet/x/windows v0.1.2 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/dlclark/regexp2 v1.11.0 // indirect 26 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 27 | github.com/gorilla/css v1.0.1 // indirect 28 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 29 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/mattn/go-localereader v0.0.1 // indirect 32 | github.com/mattn/go-runewidth v0.0.15 // indirect 33 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect 34 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 35 | github.com/muesli/cancelreader v0.2.2 // indirect 36 | github.com/muesli/reflow v0.3.0 // indirect 37 | github.com/muesli/termenv v0.15.2 // indirect 38 | github.com/olekukonko/tablewriter v0.0.5 // indirect 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/spf13/pflag v1.0.5 // indirect 42 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 43 | github.com/yuin/goldmark v1.7.4 // indirect 44 | github.com/yuin/goldmark-emoji v1.0.3 // indirect 45 | golang.org/x/net v0.26.0 // indirect 46 | golang.org/x/sync v0.7.0 // indirect 47 | golang.org/x/sys v0.21.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 2 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 4 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 10 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 11 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 12 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 13 | github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= 14 | github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= 15 | github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= 16 | github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= 17 | github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= 18 | github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= 19 | github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= 20 | github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 21 | github.com/charmbracelet/x/input v0.1.2 h1:QJAZr33eOhDowkkEQ24rsJy4Llxlm+fRDf/cQrmqJa0= 22 | github.com/charmbracelet/x/input v0.1.2/go.mod h1:LGBim0maUY4Pitjn/4fHnuXb4KirU3DODsyuHuXdOyA= 23 | github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= 24 | github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= 25 | github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= 26 | github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= 27 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 28 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 31 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 34 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 35 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 36 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 37 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 41 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 42 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 43 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 44 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 45 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 46 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 47 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 48 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 49 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 50 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= 51 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 54 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 55 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 56 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 57 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 58 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 59 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 60 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 61 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 65 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 67 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 68 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 69 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 70 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 71 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 72 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 73 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 74 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 76 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 77 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 78 | github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= 79 | github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 80 | github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= 81 | github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 82 | go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= 83 | go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= 84 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 85 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 86 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 87 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 88 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 89 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 90 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 93 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 94 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 95 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 96 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 97 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 99 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 100 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/theredditbandit/pman/cmd" 8 | ) 9 | 10 | func main() { 11 | log.SetFlags(log.Lshortfile | log.LstdFlags) 12 | err := cmd.Execute() 13 | if err != nil { 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | 11 | bolt "go.etcd.io/bbolt" 12 | ) 13 | 14 | const DBName string = "projects" 15 | const DBTestName string = "projects_test" 16 | 17 | var ( 18 | ErrOpenDB = errors.New("error opening database") 19 | ErrCreateBucket = errors.New("error creating bucket") 20 | ErrWriteToDB = errors.New("error writing to database") 21 | ErrBucketNotFound = errors.New("bucket not found") 22 | ErrProjectNotFound = errors.New("project not found") 23 | ErrDeleteFromDB = errors.New("error deleting from database") 24 | ErrKeyNotFound = errors.New("key not found in db") 25 | ErrListAllRecords = errors.New("error listing all records") 26 | ErrClearDB = errors.New("error clearing database") 27 | ErrDBNameEmpty = errors.New("dbname cannot be empty") 28 | ) 29 | 30 | // WriteToDB writes the data to the specified bucket in the database 31 | func WriteToDB(dbname string, data map[string]string, bucketName string) error { 32 | dbLoc, err := GetDBLoc(dbname) 33 | if err != nil { 34 | log.Printf("%v : %v \n", ErrOpenDB, err) 35 | return errors.Join(ErrOpenDB, err) 36 | } 37 | db, _ := bolt.Open(dbLoc, 0o600, nil) // create the database if it doesn't exist then open it 38 | defer db.Close() 39 | err = db.Update(func(tx *bolt.Tx) error { 40 | bucket, err := tx.CreateBucketIfNotExists([]byte(bucketName)) 41 | if err != nil { 42 | return errors.Join(ErrCreateBucket, err) 43 | } 44 | for k, v := range data { 45 | err = bucket.Put([]byte(k), []byte(v)) 46 | if err != nil { 47 | return errors.Join(ErrWriteToDB, err) 48 | } 49 | } 50 | return nil 51 | }) 52 | return err 53 | } 54 | 55 | func DeleteFromDb(dbname, key, bucketName string) error { 56 | dbLoc, err := GetDBLoc(dbname) 57 | if err != nil { 58 | log.Printf("%v : %v \n", ErrOpenDB, err) 59 | return errors.Join(ErrOpenDB, err) 60 | } 61 | db, _ := bolt.Open(dbLoc, 0o600, nil) // create the database if it doesn't exist then open it 62 | defer db.Close() 63 | err = db.Update(func(tx *bolt.Tx) error { 64 | bucket := tx.Bucket([]byte(bucketName)) 65 | if bucket == nil { 66 | return ErrBucketNotFound 67 | } 68 | return bucket.Delete([]byte(key)) 69 | }) 70 | return err 71 | } 72 | 73 | // getDBLoc returns the path to the database file, creating the directory if it doesn't exist 74 | func GetDBLoc(dbname string) (string, error) { 75 | usr, err := user.Current() 76 | if err != nil { 77 | return "", err 78 | } 79 | if dbname == "" { 80 | return "", ErrDBNameEmpty 81 | } 82 | dbname = dbname + ".db" 83 | dbPath := filepath.Join(usr.HomeDir, ".local", "share", "pman", dbname) 84 | if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) { 85 | err = os.MkdirAll(filepath.Dir(dbPath), 0o755) 86 | if err != nil { 87 | return "", err 88 | } 89 | } 90 | return dbPath, nil 91 | } 92 | 93 | // GetRecord returns the value of the key from the specified bucket, and error if it does not exist 94 | func GetRecord(dbname, key, bucketName string) (string, error) { 95 | var rec string 96 | dbLoc, err := GetDBLoc(dbname) 97 | if err != nil { 98 | log.Printf("%v : %v \n", ErrOpenDB, err) 99 | return "", err 100 | } 101 | db, _ := bolt.Open(dbLoc, 0o600, nil) 102 | 103 | defer db.Close() 104 | err = db.View(func(tx *bolt.Tx) error { 105 | bucket := tx.Bucket([]byte(bucketName)) 106 | if bucket == nil { 107 | // log.Println("bucket not found error", bucketName,"for key") 108 | return ErrBucketNotFound 109 | } 110 | v := bucket.Get([]byte(key)) 111 | if v == nil { 112 | // log.Println("key not found error", key) 113 | return ErrKeyNotFound 114 | } 115 | rec = string(v) 116 | return nil 117 | }) 118 | if err != nil { 119 | return "", err 120 | } 121 | return rec, nil 122 | } 123 | 124 | // GetAllRecords returns all the records from the specified bucket as a dictionary 125 | func GetAllRecords(dbname, bucketName string) (map[string]string, error) { 126 | dbLoc, err := GetDBLoc(dbname) 127 | if err != nil { 128 | log.Printf("%v : %v \n", ErrOpenDB, err) 129 | 130 | return map[string]string{}, err 131 | } 132 | db, _ := bolt.Open(dbLoc, 0o600, nil) 133 | defer db.Close() 134 | records := make(map[string]string) 135 | err = db.View(func(tx *bolt.Tx) error { 136 | bucket := tx.Bucket([]byte(bucketName)) 137 | if bucket == nil { 138 | fmt.Print("Database not found \nThis could be because no project dir has been initialized yet \n") 139 | return ErrBucketNotFound 140 | } 141 | err := bucket.ForEach(func(k, v []byte) error { 142 | records[string(k)] = string(v) 143 | return nil 144 | }) 145 | if err != nil { 146 | return errors.Join(ErrListAllRecords, err) 147 | } 148 | return nil 149 | }) 150 | if err != nil { 151 | return nil, err 152 | } 153 | return records, nil 154 | } 155 | 156 | // UpdateRec updates the value of the key in the specified bucket, usually used to update the status of a project 157 | func UpdateRec(dbname, key, val, bucketName string) error { 158 | dbLoc, err := GetDBLoc(dbname) 159 | if err != nil { 160 | log.Print(err) 161 | return err 162 | } 163 | db, _ := bolt.Open(dbLoc, 0o600, nil) 164 | 165 | defer db.Close() 166 | err = db.Update(func(tx *bolt.Tx) error { 167 | bucket := tx.Bucket([]byte(bucketName)) 168 | if bucket == nil { 169 | return ErrBucketNotFound 170 | } 171 | v := bucket.Get([]byte(key)) 172 | if v == nil { 173 | return ErrProjectNotFound 174 | } 175 | err := bucket.Put([]byte(key), []byte(val)) 176 | return err 177 | }) 178 | return err 179 | } 180 | 181 | func DeleteDb(dbname string) error { 182 | dbLoc, err := GetDBLoc(dbname) 183 | if err != nil { 184 | return err 185 | } 186 | return os.Remove(dbLoc) 187 | } 188 | -------------------------------------------------------------------------------- /pkg/db/db_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | bolt "go.etcd.io/bbolt" 10 | 11 | "github.com/theredditbandit/pman/pkg/db" 12 | ) 13 | 14 | const ( 15 | dbname = db.DBTestName 16 | bucketName = "testBucket" 17 | key = "testKey" 18 | ) 19 | 20 | func Test_GetDBLoc(t *testing.T) { 21 | t.Run("Test getDBLoc", func(t *testing.T) { 22 | expectedWords := []string{".local", "share", "pman"} 23 | 24 | actualPath, err := db.GetDBLoc(dbname) 25 | 26 | t.Cleanup(func() { 27 | _ = os.Remove(actualPath) 28 | }) 29 | 30 | require.NoError(t, err) 31 | assert.Contains(t, actualPath, expectedWords[0]) 32 | assert.Contains(t, actualPath, expectedWords[1]) 33 | assert.Contains(t, actualPath, expectedWords[2]) 34 | assert.Contains(t, actualPath, db.DBTestName) 35 | }) 36 | 37 | t.Run("Test GetDBLoc with empty dbname", func(t *testing.T) { 38 | dbname := "" 39 | expectedErr := db.ErrDBNameEmpty 40 | 41 | actualPath, err := db.GetDBLoc(dbname) 42 | 43 | require.ErrorIs(t, err, expectedErr) 44 | assert.Empty(t, actualPath) 45 | }) 46 | } 47 | 48 | func Test_GetRecord(t *testing.T) { 49 | t.Run("Test GetRecord", func(t *testing.T) { 50 | actualPath, err := db.GetDBLoc(dbname) 51 | require.NoError(t, err) 52 | 53 | t.Cleanup(func() { 54 | _ = os.Remove(actualPath) 55 | }) 56 | 57 | expectedValue := "testValue" 58 | 59 | err = db.WriteToDB(dbname, map[string]string{key: expectedValue}, bucketName) 60 | require.NoError(t, err) 61 | 62 | actualValue, err := db.GetRecord(dbname, key, bucketName) 63 | require.NoError(t, err) 64 | assert.Equal(t, expectedValue, actualValue) 65 | }) 66 | 67 | t.Run("Test GetRecord with empty dbname", func(t *testing.T) { 68 | dbname := "" 69 | key := "testKey" 70 | bucketName := "testBucket" 71 | expectedErr := db.ErrDBNameEmpty 72 | 73 | actualValue, err := db.GetRecord(dbname, key, bucketName) 74 | 75 | require.ErrorIs(t, err, expectedErr) 76 | assert.Empty(t, actualValue) 77 | }) 78 | 79 | t.Run("Test GetRecord with key not found", func(t *testing.T) { 80 | expectedErr := db.ErrKeyNotFound 81 | actualPath, err := db.GetDBLoc(dbname) 82 | require.NoError(t, err) 83 | 84 | t.Cleanup(func() { 85 | _ = os.Remove(actualPath) 86 | }) 87 | 88 | err = db.WriteToDB(dbname, map[string]string{}, bucketName) 89 | require.NoError(t, err) 90 | 91 | actualValue, err := db.GetRecord(dbname, key, bucketName) 92 | 93 | require.ErrorIs(t, err, expectedErr) 94 | assert.Empty(t, actualValue) 95 | }) 96 | 97 | t.Run("Test GetRecord with bucket not found", func(t *testing.T) { 98 | expectedErr := db.ErrBucketNotFound 99 | 100 | actualValue, err := db.GetRecord(dbname, key, bucketName) 101 | 102 | require.ErrorIs(t, err, expectedErr) 103 | assert.Empty(t, actualValue) 104 | }) 105 | } 106 | func Test_WriteToDB(t *testing.T) { 107 | t.Run("Test WriteToDB", func(t *testing.T) { 108 | actualPath, err := db.GetDBLoc(dbname) 109 | require.NoError(t, err) 110 | 111 | t.Cleanup(func() { 112 | _ = os.Remove(actualPath) 113 | }) 114 | 115 | data := map[string]string{ 116 | "key1": "value1", 117 | "key2": "value2", 118 | "key3": "value3", 119 | } 120 | bucketName := "testBucket" 121 | 122 | err = db.WriteToDB(dbname, data, bucketName) 123 | require.NoError(t, err) 124 | 125 | // Verify that the data was written correctly 126 | db, err := bolt.Open(actualPath, 0o600, nil) 127 | require.NoError(t, err) 128 | defer db.Close() 129 | 130 | err = db.View(func(tx *bolt.Tx) error { 131 | bucket := tx.Bucket([]byte(bucketName)) 132 | assert.NotNil(t, bucket) 133 | 134 | for k, v := range data { 135 | value := bucket.Get([]byte(k)) 136 | assert.Equal(t, []byte(v), value) 137 | } 138 | 139 | return nil 140 | }) 141 | require.NoError(t, err) 142 | }) 143 | 144 | t.Run("Test WriteToDB with empty bucket name", func(t *testing.T) { 145 | actualPath, err := db.GetDBLoc(dbname) 146 | require.NoError(t, err) 147 | 148 | t.Cleanup(func() { 149 | _ = os.Remove(actualPath) 150 | }) 151 | 152 | data := map[string]string{ 153 | "key1": "value1", 154 | "key2": "value2", 155 | "key3": "value3", 156 | } 157 | bucketName := "" 158 | 159 | err = db.WriteToDB(dbname, data, bucketName) 160 | 161 | require.ErrorIs(t, err, db.ErrCreateBucket) 162 | }) 163 | 164 | t.Run("Test WriteToDB with empty map key", func(t *testing.T) { 165 | actualPath, err := db.GetDBLoc(dbname) 166 | require.NoError(t, err) 167 | 168 | t.Cleanup(func() { 169 | os.Remove(actualPath) 170 | }) 171 | 172 | data := map[string]string{ 173 | "": "value1", 174 | } 175 | 176 | err = db.WriteToDB(dbname, data, bucketName) 177 | 178 | require.ErrorIs(t, err, db.ErrWriteToDB) 179 | }) 180 | 181 | t.Run("Test WriteToDB with empty dbname value", func(t *testing.T) { 182 | dbname := "" 183 | data := map[string]string{ 184 | "key1": "value1", 185 | "key2": "value2", 186 | "key3": "value3", 187 | } 188 | bucketName := "testBucket" 189 | 190 | err := db.WriteToDB(dbname, data, bucketName) 191 | 192 | require.ErrorIs(t, err, db.ErrOpenDB) 193 | }) 194 | } 195 | 196 | func Test_DeleteFromDb(t *testing.T) { 197 | t.Run("Test DeleteFromDb", func(t *testing.T) { 198 | 199 | actualPath, err := db.GetDBLoc(dbname) 200 | require.NoError(t, err) 201 | 202 | t.Cleanup(func() { 203 | os.Remove(actualPath) 204 | }) 205 | 206 | data := map[string]string{ 207 | "key1": "value1", 208 | "key2": "value2", 209 | "key3": "value3", 210 | } 211 | key := "key1" 212 | 213 | err = db.WriteToDB(dbname, data, bucketName) 214 | require.NoError(t, err) 215 | 216 | err = db.DeleteFromDb(dbname, key, bucketName) 217 | require.NoError(t, err) 218 | 219 | // Verify that the key was deleted 220 | db, err := bolt.Open(actualPath, 0o600, nil) 221 | require.NoError(t, err) 222 | defer db.Close() 223 | 224 | err = db.View(func(tx *bolt.Tx) error { 225 | bucket := tx.Bucket([]byte(bucketName)) 226 | assert.NotNil(t, bucket) 227 | 228 | value := bucket.Get([]byte(key)) 229 | assert.Nil(t, value) 230 | 231 | return nil 232 | }) 233 | require.NoError(t, err) 234 | }) 235 | 236 | t.Run("Test DeleteFromDb with empty dbname", func(t *testing.T) { 237 | dbname := "" 238 | key := "key1" 239 | expectedErr := db.ErrDBNameEmpty 240 | 241 | err := db.DeleteFromDb(dbname, key, bucketName) 242 | 243 | require.ErrorIs(t, err, expectedErr) 244 | }) 245 | 246 | t.Run("Test DeleteFromDb with key not found", func(t *testing.T) { 247 | 248 | actualPath, err := db.GetDBLoc(dbname) 249 | require.NoError(t, err) 250 | 251 | t.Cleanup(func() { 252 | os.Remove(actualPath) 253 | }) 254 | 255 | data := map[string]string{ 256 | "key1": "value1", 257 | "key2": "value2", 258 | "key3": "value3", 259 | } 260 | key := "key4" 261 | 262 | err = db.WriteToDB(dbname, data, bucketName) 263 | require.NoError(t, err) 264 | 265 | err = db.DeleteFromDb(dbname, key, bucketName) 266 | 267 | require.NoError(t, err) 268 | }) 269 | 270 | t.Run("Test DeleteFromDb with bucket not found", func(t *testing.T) { 271 | 272 | key := "key1" 273 | expectedErr := db.ErrBucketNotFound 274 | 275 | err := db.DeleteFromDb(dbname, key, bucketName) 276 | 277 | require.ErrorIs(t, err, expectedErr) 278 | }) 279 | } 280 | 281 | func Test_ListAllRecords(t *testing.T) { 282 | t.Run("Test ListAllRecords", func(t *testing.T) { 283 | 284 | actualPath, err := db.GetDBLoc(dbname) 285 | require.NoError(t, err) 286 | 287 | t.Cleanup(func() { 288 | os.Remove(actualPath) 289 | }) 290 | 291 | data := map[string]string{ 292 | "key1": "value1", 293 | "key2": "value2", 294 | "key3": "value3", 295 | } 296 | 297 | err = db.WriteToDB(dbname, data, bucketName) 298 | require.NoError(t, err) 299 | 300 | records, err := db.GetAllRecords(dbname, bucketName) 301 | 302 | require.NoError(t, err) 303 | assert.Equal(t, data, records) 304 | }) 305 | 306 | t.Run("Test ListAllRecords with empty dbname", func(t *testing.T) { 307 | dbname := "" 308 | expectedErr := db.ErrDBNameEmpty 309 | expectedValue := map[string]string{} 310 | 311 | records, err := db.GetAllRecords(dbname, bucketName) 312 | 313 | require.ErrorIs(t, err, expectedErr) 314 | assert.Equal(t, expectedValue, records) 315 | }) 316 | 317 | t.Run("Test ListAllRecords with bucket not found", func(t *testing.T) { 318 | expectedErr := db.ErrBucketNotFound 319 | 320 | records, err := db.GetAllRecords(dbname, bucketName) 321 | 322 | require.ErrorIs(t, err, expectedErr) 323 | assert.Nil(t, records) 324 | }) 325 | } 326 | func Test_UpdateRec(t *testing.T) { 327 | t.Run("Test UpdateRec", func(t *testing.T) { 328 | 329 | actualPath, err := db.GetDBLoc(dbname) 330 | require.NoError(t, err) 331 | 332 | t.Cleanup(func() { 333 | os.Remove(actualPath) 334 | }) 335 | 336 | data := map[string]string{ 337 | "key1": "value1", 338 | "key2": "value2", 339 | "key3": "value3", 340 | } 341 | bucketName := "testBucket" 342 | key := "key1" 343 | newValue := "updatedValue" 344 | 345 | err = db.WriteToDB(dbname, data, bucketName) 346 | require.NoError(t, err) 347 | 348 | err = db.UpdateRec(dbname, key, newValue, bucketName) 349 | require.NoError(t, err) 350 | 351 | // Verify that the value was updated 352 | db, err := bolt.Open(actualPath, 0o600, nil) 353 | require.NoError(t, err) 354 | defer db.Close() 355 | 356 | err = db.View(func(tx *bolt.Tx) error { 357 | bucket := tx.Bucket([]byte(bucketName)) 358 | assert.NotNil(t, bucket) 359 | 360 | value := bucket.Get([]byte(key)) 361 | assert.Equal(t, []byte(newValue), value) 362 | return nil 363 | }) 364 | require.NoError(t, err) 365 | }) 366 | 367 | t.Run("Test UpdateRec with empty dbname", func(t *testing.T) { 368 | dbname := "" 369 | key := "key1" 370 | newValue := "updatedValue" 371 | err := db.UpdateRec(dbname, key, newValue, bucketName) 372 | 373 | require.ErrorIs(t, err, db.ErrDBNameEmpty) 374 | }) 375 | 376 | t.Run("Test UpdateRec with key not found", func(t *testing.T) { 377 | 378 | actualPath, err := db.GetDBLoc(dbname) 379 | require.NoError(t, err) 380 | 381 | t.Cleanup(func() { 382 | os.Remove(actualPath) 383 | }) 384 | 385 | data := map[string]string{ 386 | "key1": "value1", 387 | "key2": "value2", 388 | "key3": "value3", 389 | } 390 | key := "key4" 391 | newValue := "updatedValue" 392 | 393 | err = db.WriteToDB(dbname, data, bucketName) 394 | require.NoError(t, err) 395 | 396 | err = db.UpdateRec(dbname, key, newValue, bucketName) 397 | 398 | require.ErrorIs(t, err, db.ErrProjectNotFound) 399 | }) 400 | 401 | t.Run("Test UpdateRec with bucket not found", func(t *testing.T) { 402 | 403 | key := "key1" 404 | newValue := "updatedValue" 405 | expectedErr := db.ErrBucketNotFound 406 | 407 | err := db.UpdateRec(dbname, key, newValue, bucketName) 408 | 409 | require.ErrorIs(t, err, expectedErr) 410 | }) 411 | } 412 | 413 | func Test_DeleteDb(t *testing.T) { 414 | t.Run("Test DeleteDb", func(t *testing.T) { 415 | actualPath, err := db.GetDBLoc(dbname) 416 | require.NoError(t, err) 417 | 418 | t.Cleanup(func() { 419 | os.Remove(actualPath) 420 | }) 421 | 422 | err = db.DeleteDb(dbname) 423 | require.NoError(t, err) 424 | 425 | // Verify that the database file is deleted 426 | _, err = os.Stat(actualPath) 427 | assert.True(t, os.IsNotExist(err)) 428 | }) 429 | 430 | t.Run("Test DeleteDb with empty dbname", func(t *testing.T) { 431 | dbname := "" 432 | expectedErr := db.ErrDBNameEmpty 433 | 434 | err := db.DeleteDb(dbname) 435 | 436 | require.ErrorIs(t, err, expectedErr) 437 | }) 438 | } 439 | -------------------------------------------------------------------------------- /pkg/indexer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | c "github.com/theredditbandit/pman/constants" 13 | "github.com/theredditbandit/pman/pkg/db" 14 | "github.com/theredditbandit/pman/pkg/utils" 15 | ) 16 | 17 | var ( 18 | ErrDirname = errors.New("error providing a directory name") 19 | ErrDirInvalid = errors.New("error providing a valid directory name") 20 | ErrIsNotDir = errors.New("error providing a file instead of a directory") 21 | ErrIndexDir = errors.New("error indexing directory") 22 | ) 23 | 24 | // InitDirs indexes a directory for project directories and writes the data to the DB 25 | func InitDirs(args []string) error { 26 | // the file which identifies a project directory 27 | if len(args) != 1 { 28 | log.Print("Please provide a directory name") 29 | return ErrDirname 30 | } 31 | dirname := args[0] 32 | if stat, err := os.Stat(dirname); os.IsNotExist(err) { 33 | log.Printf("%s is not a directory \n", dirname) 34 | return ErrDirInvalid 35 | } else if !stat.IsDir() { 36 | log.Printf("%s is a file and not a directory \n", dirname) 37 | return ErrIsNotDir 38 | } 39 | projDirs, err := indexDir(dirname) 40 | if err != nil { 41 | log.Print(err) 42 | return ErrIndexDir 43 | } 44 | fmt.Printf("Indexed %d project directories . . .\n", len(projDirs)) 45 | projectStatusMap := make(map[string]string) 46 | projectPathMap := make(map[string]string) 47 | projectLastModTimeMap := make(map[string]string) 48 | for k, v := range projDirs { // k : full project path, v : project status , 49 | projectName := filepath.Base(k) 50 | projectStatusMap[projectName] = v // filepath.Base(k) : project name 51 | projectPathMap[projectName] = k 52 | } 53 | err = db.WriteToDB(db.DBName, projectStatusMap, c.StatusBucket) 54 | if err != nil { 55 | log.Print(err) 56 | return err 57 | } 58 | err = db.WriteToDB(db.DBName, projectPathMap, c.ProjectPaths) 59 | if err != nil { 60 | log.Print(err) 61 | return err 62 | } 63 | 64 | for k := range projDirs { 65 | projectName := filepath.Base(k) 66 | t := utils.GetLastModifiedTime(db.DBName, projectName) 67 | lastEdited, timestamp := utils.ParseTime(t) 68 | projectLastModTimeMap[projectName] = fmt.Sprintf("%s-%d", lastEdited, timestamp) 69 | } 70 | 71 | err = db.WriteToDB(db.DBName, projectLastModTimeMap, c.LastUpdatedBucket) 72 | if err != nil { 73 | log.Print(err) 74 | return err 75 | } 76 | 77 | lastEdit := make(map[string]string) 78 | lastEdit["lastWrite"] = fmt.Sprint(time.Now().Format("02 Jan 06 15:04")) 79 | err = db.WriteToDB(db.DBName, lastEdit, c.ConfigBucket) 80 | if err != nil { 81 | log.Print(err) 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // indexDir indexes a directory for project directories 89 | func indexDir(path string) (map[string]string, error) { 90 | identifier := "readme" 91 | projDirs := make(map[string]string) 92 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 93 | if err != nil { 94 | return err 95 | } 96 | if info.IsDir() && info.Name() == ".git" { 97 | pname := filepath.Dir(path) 98 | absPath, _ := filepath.Abs(pname) 99 | projDirs[absPath] = "indexed" 100 | return filepath.SkipDir 101 | } 102 | if !info.IsDir() { 103 | fileName := strings.ToLower(info.Name()) 104 | if strings.Contains(fileName, identifier) { 105 | pname := filepath.Dir(path) 106 | absPath, _ := filepath.Abs(pname) 107 | projDirs[absPath] = "indexed" 108 | return filepath.SkipDir 109 | } 110 | } 111 | 112 | return nil 113 | }) 114 | if err != nil { 115 | return nil, err 116 | } 117 | return projDirs, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/ui/interactiveTable.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/table" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | 13 | c "github.com/theredditbandit/pman/constants" 14 | "github.com/theredditbandit/pman/pkg/db" 15 | pgr "github.com/theredditbandit/pman/pkg/ui/pager" 16 | "github.com/theredditbandit/pman/pkg/utils" 17 | ) 18 | 19 | var baseStyle = lipgloss.NewStyle(). 20 | BorderStyle(lipgloss.NormalBorder()). 21 | BorderForeground(lipgloss.Color("240")) 22 | 23 | type tableModel struct { 24 | table table.Model 25 | } 26 | 27 | func (tableModel) Init() tea.Cmd { return nil } 28 | 29 | func (m tableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 30 | var cmd tea.Cmd 31 | if msg, ok := msg.(tea.KeyMsg); ok { 32 | switch msg.String() { 33 | case "esc": 34 | if m.table.Focused() { 35 | m.table.Blur() 36 | } else { 37 | m.table.Focus() 38 | } 39 | case "q", "ctrl+c": 40 | return m, tea.Quit 41 | case "enter": 42 | project := m.table.SelectedRow()[1] 43 | if strings.Contains(project, ")") { // project is of the form a-long-project-name (alias) 44 | projectAliasArr := strings.Split(project, " ") 45 | project = projectAliasArr[0] 46 | } 47 | err := pgr.LaunchRenderer(project) 48 | if err != nil { 49 | return m, tea.Quit 50 | } 51 | } 52 | } 53 | m.table, cmd = m.table.Update(msg) 54 | return m, cmd 55 | } 56 | 57 | func (m tableModel) View() string { 58 | return baseStyle.Render(m.table.View()) + "\n" 59 | } 60 | 61 | func RenderInteractiveTable(data map[string]string, refreshLastEditedTime bool) error { 62 | var rows []table.Row 63 | var lastEdited string 64 | var timestamp int64 65 | 66 | col := []table.Column{ 67 | {Title: "Status", Width: 20}, 68 | {Title: "Project", Width: 40}, 69 | {Title: "Last Edited", Width: 20}, 70 | } 71 | 72 | if refreshLastEditedTime { 73 | err := utils.UpdateLastEditedTime() 74 | if err != nil { 75 | return err 76 | } 77 | } else { 78 | rec, err := db.GetRecord(db.DBName, "lastRefreshTime", c.ConfigBucket) 79 | if err != nil { // lastRefreshTime key does not exist in db 80 | refreshLastEditedTime = true 81 | err := utils.UpdateLastEditedTime() 82 | if err != nil { 83 | return err 84 | } 85 | } 86 | if utils.DayPassed(rec) { // lastEdited values are more than a day old. need to refresh them 87 | refreshLastEditedTime = true 88 | err := utils.UpdateLastEditedTime() 89 | if err != nil { 90 | return err 91 | } 92 | } 93 | } 94 | 95 | for proj, status := range data { 96 | alias, err := db.GetRecord(db.DBName, proj, c.ProjectAliasBucket) 97 | if refreshLastEditedTime { 98 | t := utils.GetLastModifiedTime(db.DBName, proj) 99 | lastEdited, timestamp = utils.ParseTime(t) 100 | rec := map[string]string{proj: fmt.Sprintf("%s-%d", lastEdited, timestamp)} 101 | err := db.WriteToDB(db.DBName, rec, c.LastUpdatedBucket) 102 | if err != nil { 103 | return err 104 | } 105 | } else { 106 | lE, err := db.GetRecord(db.DBName, proj, c.LastUpdatedBucket) 107 | if err != nil { 108 | return err 109 | } 110 | out := strings.Split(lE, "-") 111 | lastEdited = out[0] 112 | tstmp, err := strconv.ParseInt(out[1], 10, 64) 113 | if err != nil { 114 | return err 115 | } 116 | timestamp = tstmp 117 | } 118 | if err == nil { 119 | pname := fmt.Sprintf("%s (%s)", proj, alias) 120 | row := []string{utils.TitleCase(status), pname, lastEdited, fmt.Sprint(timestamp)} // Status | projectName (alias) | lastEdited | timestamp 121 | rows = append(rows, row) 122 | } else { 123 | row := []string{utils.TitleCase(status), proj, lastEdited, fmt.Sprint(timestamp)} // Status | projectName | lastEdited | timestamp 124 | rows = append(rows, row) 125 | } 126 | } 127 | 128 | if len(rows) == 0 { 129 | fmt.Printf("No projects found in the database\n\n") 130 | fmt.Printf("Add projects to the database using \n\n") 131 | fmt.Println("pman init .") 132 | fmt.Println("or") 133 | fmt.Println("pman add ") 134 | return nil 135 | } 136 | sort.Slice(rows, func(i, j int) bool { 137 | valI, _ := strconv.ParseInt(rows[i][3], 10, 64) 138 | valJ, _ := strconv.ParseInt(rows[j][3], 10, 64) 139 | return valI > valJ 140 | }) 141 | cleanUp := func(r []table.Row) []table.Row { 142 | result := make([]table.Row, len(r)) 143 | for i, inner := range r { 144 | n := len(inner) 145 | result[i] = inner[:n-1] 146 | } 147 | return result 148 | } 149 | rows = cleanUp(rows) 150 | t := table.New( 151 | table.WithColumns(col), 152 | table.WithRows(rows), 153 | table.WithFocused(true), 154 | table.WithHeight(10), 155 | table.WithWidth(90), 156 | ) 157 | s := table.DefaultStyles() 158 | s.Header = s.Header. 159 | BorderStyle(lipgloss.NormalBorder()). 160 | BorderForeground(lipgloss.Color("240")). 161 | BorderBottom(true). 162 | Bold(false) 163 | s.Selected = s.Selected. 164 | Foreground(lipgloss.Color("229")). 165 | Background(lipgloss.Color("57")). 166 | Bold(false) 167 | t.SetStyles(s) 168 | 169 | m := tableModel{t} 170 | if _, err := tea.NewProgram(m).Run(); err != nil { 171 | return fmt.Errorf("error running program: %w", err) 172 | } 173 | return nil 174 | } 175 | -------------------------------------------------------------------------------- /pkg/ui/pager/renderer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | 11 | "github.com/theredditbandit/pman/pkg/db" 12 | "github.com/theredditbandit/pman/pkg/utils" 13 | ) 14 | 15 | const useHighPerformanceRenderer = true 16 | 17 | var ( 18 | pagerTitleStyle = func() lipgloss.Style { 19 | b := lipgloss.RoundedBorder() 20 | b.Right = "$" 21 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 22 | }() 23 | 24 | infoStyle = func() lipgloss.Style { 25 | b := lipgloss.RoundedBorder() 26 | b.Left = "$" 27 | return pagerTitleStyle.BorderStyle(b) 28 | }() 29 | ) 30 | 31 | type model struct { 32 | content string 33 | ready bool 34 | viewport viewport.Model 35 | project string 36 | } 37 | 38 | func (model) Init() tea.Cmd { 39 | return nil 40 | } 41 | 42 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 43 | var ( 44 | cmd tea.Cmd 45 | cmds []tea.Cmd 46 | ) 47 | 48 | switch msg := msg.(type) { 49 | case tea.KeyMsg: 50 | if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { 51 | return m, tea.Quit 52 | } else if k := msg.String(); k == "enter" { 53 | return m, tea.Quit 54 | } 55 | 56 | case tea.WindowSizeMsg: 57 | headerHeight := lipgloss.Height(m.headerView()) 58 | footerHeight := lipgloss.Height(m.footerView()) 59 | verticalMarginHeight := headerHeight + footerHeight 60 | 61 | if !m.ready { 62 | m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 63 | m.viewport.YPosition = headerHeight 64 | m.viewport.HighPerformanceRendering = useHighPerformanceRenderer 65 | m.viewport.SetContent(m.content) 66 | m.ready = true 67 | 68 | m.viewport.YPosition = headerHeight + 1 69 | } else { 70 | m.viewport.Width = msg.Width 71 | m.viewport.Height = msg.Height - verticalMarginHeight 72 | } 73 | 74 | if useHighPerformanceRenderer { 75 | cmds = append(cmds, viewport.Sync(m.viewport)) 76 | } 77 | } 78 | 79 | m.viewport, cmd = m.viewport.Update(msg) 80 | cmds = append(cmds, cmd) 81 | 82 | return m, tea.Batch(cmds...) 83 | } 84 | 85 | func (m model) View() string { 86 | if !m.ready { 87 | return "\n Initializing..." 88 | } 89 | return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) 90 | } 91 | 92 | func (m model) headerView() string { 93 | title := pagerTitleStyle.Render(m.project) 94 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) 95 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 96 | } 97 | 98 | func (m model) footerView() string { 99 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 100 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 101 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 102 | } 103 | 104 | func LaunchRenderer(file string) error { 105 | content, err := utils.ReadREADME(db.DBName, file) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | breautifulContent, err := utils.BeautifyMD(content) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | p := tea.NewProgram( 116 | model{ 117 | content: breautifulContent, 118 | project: file, 119 | }, 120 | tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" 121 | tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel 122 | ) 123 | 124 | if _, err := p.Run(); err != nil { 125 | return fmt.Errorf("could not run pager: %w", err) 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/ui/statusTable.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/charmbracelet/lipgloss/table" 13 | 14 | c "github.com/theredditbandit/pman/constants" 15 | "github.com/theredditbandit/pman/pkg/db" 16 | "github.com/theredditbandit/pman/pkg/utils" 17 | ) 18 | 19 | // RenderTable: renders the given data as a table 20 | func RenderTable(data map[string]string, refreshLastEditedTime bool) error { 21 | var tableData [][]string 22 | var lastEdited string 23 | var timestamp int64 24 | 25 | if refreshLastEditedTime { 26 | err := utils.UpdateLastEditedTime() 27 | if err != nil { 28 | return err 29 | } 30 | } else { 31 | rec, err := db.GetRecord(db.DBName, "lastRefreshTime", c.ConfigBucket) 32 | if err != nil { // lastRefreshTime key does not exist in db 33 | refreshLastEditedTime = true 34 | err := utils.UpdateLastEditedTime() 35 | if err != nil { 36 | return err 37 | } 38 | } 39 | if utils.DayPassed(rec) { // lastEdited values are more than a day old. need to refresh them 40 | refreshLastEditedTime = true 41 | err := utils.UpdateLastEditedTime() 42 | if err != nil { 43 | return err 44 | } 45 | } 46 | } 47 | for p, status := range data { 48 | // log.Println("===============================================") 49 | alias, err := db.GetRecord(db.DBName, p, c.ProjectAliasBucket) 50 | // log.Println("after get record func", " err=", err) 51 | // log.Println("p=", p) 52 | // log.Println("status=", status) 53 | if refreshLastEditedTime { 54 | t := utils.GetLastModifiedTime(db.DBName, p) 55 | lastEdited, timestamp = utils.ParseTime(t) 56 | rec := map[string]string{p: fmt.Sprintf("%s-%d", lastEdited, timestamp)} 57 | err := db.WriteToDB(db.DBName, rec, c.LastUpdatedBucket) 58 | if err != nil { 59 | return err 60 | } 61 | } else { 62 | // log.Println("b4 key nf error", "p=", p, "bucket=", c.LastUpdatedBucket) 63 | lE, err := db.GetRecord(db.DBName, p, c.LastUpdatedBucket) 64 | if err != nil { 65 | log.Println("aftert key nf error") 66 | return err 67 | } 68 | out := strings.Split(lE, "-") 69 | lastEdited = out[0] 70 | tstmp, err := strconv.ParseInt(out[1], 10, 64) 71 | if err != nil { 72 | return err 73 | } 74 | timestamp = tstmp 75 | } 76 | if err == nil { 77 | pname := fmt.Sprintf("%s (%s)", p, alias) 78 | row := []string{utils.TitleCase(status), pname, lastEdited, fmt.Sprint(timestamp)} // Status | projectName (alias) | lastEdited | timestamp 79 | tableData = append(tableData, row) 80 | } else { 81 | row := []string{utils.TitleCase(status), p, lastEdited, fmt.Sprint(timestamp)} // Status | projectName | lastEdited | timestamp 82 | tableData = append(tableData, row) 83 | } 84 | } 85 | if len(tableData) == 0 { 86 | fmt.Printf("No projects found in the database\n\n") 87 | fmt.Printf("Add projects to the database using \n\n") 88 | fmt.Println("pman init .") 89 | fmt.Println("or") 90 | fmt.Println("pman add ") 91 | return fmt.Errorf("no database initialized") 92 | } 93 | sort.Slice(tableData, func(i, j int) bool { 94 | valI, _ := strconv.ParseInt(tableData[i][3], 10, 64) 95 | valJ, _ := strconv.ParseInt(tableData[j][3], 10, 64) 96 | return valI > valJ 97 | }) 98 | 99 | cleanUp := func(tbl [][]string) [][]string { // cleanUp func removes the unix timestamp col from the tabledata 100 | result := make([][]string, len(tbl)) 101 | for i, inner := range tbl { 102 | n := len(inner) 103 | result[i] = inner[:n-1] 104 | } 105 | return result 106 | } 107 | 108 | tableData = cleanUp(tableData) 109 | 110 | re := lipgloss.NewRenderer(os.Stdout) 111 | baseStyle := re.NewStyle().Padding(0, 1) 112 | headerStyle := baseStyle.Foreground(lipgloss.Color("252")).Bold(true) 113 | // selectedStyle := baseStyle.Copy().Foreground(lipgloss.Color("#01BE85")).Background(lipgloss.Color("#00432F")) 114 | statusColors := map[string]lipgloss.Color{ 115 | "Idea": lipgloss.Color("#FF87D7"), 116 | "Indexed": lipgloss.Color("#727272"), 117 | "Not Started": lipgloss.Color("#D7FF87"), 118 | "Ongoing": lipgloss.Color("#00E2C7"), 119 | "Started": lipgloss.Color("#00E2C7"), 120 | "Paused": lipgloss.Color("#7D5AFC"), 121 | "Completed": lipgloss.Color("#75FBAB"), 122 | "Aborted": lipgloss.Color("#FF875F"), 123 | "Default": lipgloss.Color("#929292"), 124 | } 125 | headers := []string{"Status", "Project Name", "Last Edited"} 126 | t := table.New(). 127 | Border(lipgloss.NormalBorder()). 128 | BorderStyle(re.NewStyle().Foreground(lipgloss.Color("238"))). 129 | Headers(headers...). 130 | Width(90). 131 | Rows(tableData...). 132 | StyleFunc(func(row, _ int) lipgloss.Style { 133 | if row == 0 { 134 | return headerStyle 135 | } 136 | color, ok := statusColors[fmt.Sprint(tableData[row-1][0])] 137 | if ok { 138 | return baseStyle.Foreground(color) 139 | } 140 | color = statusColors["Default"] 141 | return baseStyle.Foreground(color) 142 | }) 143 | fmt.Println(t) 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/charmbracelet/glamour" 13 | "golang.org/x/text/cases" 14 | "golang.org/x/text/language" 15 | 16 | c "github.com/theredditbandit/pman/constants" 17 | "github.com/theredditbandit/pman/pkg/db" 18 | ) 19 | 20 | var ( 21 | ErrBeautifyMD = errors.New("error beautifying markdown") 22 | ErrGetAlias = errors.New("error getting alias") 23 | ErrGetProject = errors.New("error getting project") 24 | ErrReadREADME = errors.New("error reading README") 25 | ) 26 | 27 | func TitleCase(s string) string { 28 | t := cases.Title(language.English) 29 | return t.String(s) 30 | } 31 | 32 | func FilterByStatuses(data map[string]string, status []string) map[string]string { 33 | filteredData := make(map[string]string) 34 | for k, v := range data { 35 | for _, s := range status { 36 | if v == s { 37 | filteredData[k] = v 38 | } 39 | } 40 | } 41 | return filteredData 42 | } 43 | 44 | // GetLastModifiedTime returns the last modified time 45 | func GetLastModifiedTime(dbname, pname string) string { 46 | var lastModTime time.Time 47 | pPath, err := db.GetRecord(dbname, pname, c.ProjectPaths) 48 | if err != nil { 49 | return "Something went wrong" 50 | } 51 | _ = filepath.Walk(pPath, func(_ string, info os.FileInfo, err error) error { 52 | if err != nil { 53 | return err 54 | } 55 | if !info.IsDir() && info.ModTime().After(lastModTime) { 56 | lastModTime = info.ModTime() 57 | } 58 | return nil 59 | }) 60 | return fmt.Sprint(lastModTime.Format("02 Jan 06 15:04")) 61 | } 62 | 63 | // BeautifyMD: returns styled markdown 64 | func BeautifyMD(data []byte) (string, error) { 65 | r, err := glamour.NewTermRenderer( 66 | glamour.WithAutoStyle(), 67 | glamour.WithWordWrap(120), 68 | glamour.WithAutoStyle(), 69 | ) 70 | if err != nil { 71 | log.Print("something went wrong while creating renderer: ", err) 72 | return "", errors.Join(ErrBeautifyMD, err) 73 | } 74 | out, _ := r.Render(string(data)) 75 | return out, nil 76 | } 77 | 78 | // GetProjectPath: returns the path to the README inside the project 79 | func GetProjectPath(dbname, projectName string) (string, error) { 80 | path, err := db.GetRecord(dbname, projectName, c.ProjectPaths) 81 | if err != nil { 82 | actualName, err := db.GetRecord(dbname, projectName, c.ProjectAliasBucket) 83 | if err != nil { 84 | log.Printf("project: %v not a valid project\n", projectName) 85 | return "", errors.Join(ErrGetAlias, err) 86 | } 87 | projectName = actualName 88 | path, err = db.GetRecord(dbname, projectName, c.ProjectPaths) 89 | if err != nil { 90 | log.Printf("project: %v not a valid project\n", projectName) 91 | return "", errors.Join(ErrGetProject, err) 92 | } 93 | } 94 | pPath := filepath.Join(path, "README.md") 95 | _, err = os.Stat(pPath) 96 | return pPath, err 97 | } 98 | 99 | // ReadREADME: returns the byte array of README.md of a project 100 | func ReadREADME(dbname, projectName string) ([]byte, error) { 101 | pPath, err := GetProjectPath(dbname, projectName) 102 | if err != nil { 103 | if os.IsNotExist(err) { 104 | msg := fmt.Sprintf("# README does not exist for %s", projectName) 105 | return []byte(msg), nil 106 | } 107 | return nil, err 108 | } 109 | data, err := os.ReadFile(pPath) 110 | if err != nil { 111 | return nil, errors.Join(ErrReadREADME, fmt.Errorf("something went wrong while reading README for %s: %w", projectName, err)) 112 | } 113 | return data, nil 114 | } 115 | 116 | func UpdateLastEditedTime() error { 117 | r := fmt.Sprint(time.Now().Unix()) 118 | rec := map[string]string{"lastRefreshTime": r} 119 | err := db.WriteToDB(db.DBName, rec, c.ConfigBucket) 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | 125 | } 126 | 127 | func DayPassed(t string) bool { 128 | oneDay := 86400 129 | now := time.Now().Unix() 130 | recTime, _ := strconv.ParseInt(t, 10, 64) 131 | return now-recTime > int64(oneDay) 132 | } 133 | 134 | func ParseTime(tstr string) (string, int64) { 135 | layout := "02 Jan 06 15:04" 136 | p, err := time.Parse(layout, tstr) 137 | timeStamp := p.Unix() 138 | if err != nil { 139 | return "unnkown", 0 140 | } 141 | today := time.Now() 142 | switch fmt.Sprint(p.Date()) { 143 | case fmt.Sprint(today.Date()): 144 | return fmt.Sprintf("Today %s", p.Format("15:04")), timeStamp 145 | case fmt.Sprint(today.AddDate(0, 0, -1).Date()): 146 | return fmt.Sprintf("Yesterday %s", p.Format("17:00")), timeStamp 147 | } 148 | return tstr, timeStamp 149 | } 150 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/charmbracelet/glamour" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | c "github.com/theredditbandit/pman/constants" 14 | "github.com/theredditbandit/pman/pkg/db" 15 | "github.com/theredditbandit/pman/pkg/utils" 16 | ) 17 | 18 | const ( 19 | dbname = db.DBTestName 20 | expectedMsg = "Something went wrong" 21 | projectName = "project_name" 22 | projectPath = "./" + projectName 23 | ) 24 | 25 | func Test_TitleCase(t *testing.T) { 26 | t.Run("Test TitleCase", func(t *testing.T) { 27 | s := "hello world" 28 | expected := "Hello World" 29 | 30 | actual := utils.TitleCase(s) 31 | 32 | assert.Equal(t, expected, actual) 33 | }) 34 | } 35 | 36 | func Test_FilterByStatus(t *testing.T) { 37 | t.Run("Test FilterByStatus", func(t *testing.T) { 38 | data := map[string]string{ 39 | "key1": "status1", 40 | "key2": "status2", 41 | "key3": "status1", 42 | } 43 | status := []string{"status1"} 44 | 45 | expectedData := map[string]string{ 46 | "key1": "status1", 47 | "key3": "status1", 48 | } 49 | 50 | actualData := utils.FilterByStatuses(data, status) 51 | 52 | assert.Equal(t, expectedData, actualData) 53 | }) 54 | 55 | t.Run("Test FilterByStatus with empty data", func(t *testing.T) { 56 | data := map[string]string{} 57 | status := []string{"status1"} 58 | 59 | expectedData := map[string]string{} 60 | 61 | actualData := utils.FilterByStatuses(data, status) 62 | 63 | assert.Equal(t, expectedData, actualData) 64 | }) 65 | } 66 | 67 | func Test_BeautifyMD(t *testing.T) { 68 | t.Run("Test BeautifyMD under normal conditions", func(t *testing.T) { 69 | data := []byte("# i am a test") 70 | r, err := glamour.NewTermRenderer( 71 | glamour.WithAutoStyle(), 72 | glamour.WithWordWrap(120), 73 | glamour.WithAutoStyle(), 74 | ) 75 | require.NoError(t, err) 76 | 77 | expected, err := r.Render(string(data)) 78 | require.NoError(t, err) 79 | 80 | actual, err := utils.BeautifyMD(data) 81 | 82 | require.NoError(t, err) 83 | assert.Equal(t, expected, actual) 84 | }) 85 | 86 | t.Run("Test BeautifyMD with empty data", func(t *testing.T) { 87 | data := []byte("") 88 | r, err := glamour.NewTermRenderer( 89 | glamour.WithAutoStyle(), 90 | glamour.WithWordWrap(120), 91 | glamour.WithAutoStyle(), 92 | ) 93 | require.NoError(t, err) 94 | 95 | expected, err := r.Render(string(data)) 96 | require.NoError(t, err) 97 | 98 | actual, err := utils.BeautifyMD(data) 99 | 100 | require.NoError(t, err) 101 | assert.Equal(t, expected, actual) 102 | }) 103 | } 104 | 105 | func Test_ReadREADME(t *testing.T) { 106 | t.Run("Test ReadREADME under normal conditions", func(t *testing.T) { 107 | expected := []byte{} 108 | 109 | t.Cleanup(func() { 110 | err := db.DeleteDb(dbname) 111 | require.NoError(t, err) 112 | _ = os.RemoveAll(projectPath) 113 | }) 114 | 115 | err := os.Mkdir(projectPath, 0755) 116 | require.NoError(t, err) 117 | f, err := os.Create(projectPath + "/README.md") 118 | require.NoError(t, err) 119 | f.Close() 120 | 121 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 122 | require.NoError(t, err) 123 | 124 | actual, err := utils.ReadREADME(dbname, projectName) 125 | 126 | assert.Equal(t, expected, actual) 127 | require.NoError(t, err) 128 | }) 129 | t.Run("Test ReadREADME with well alias and project", func(t *testing.T) { 130 | alias := "project_alias" 131 | expected := []byte{} 132 | 133 | t.Cleanup(func() { 134 | err := db.DeleteDb(dbname) 135 | require.NoError(t, err) 136 | _ = os.RemoveAll(projectPath) 137 | }) 138 | 139 | err := os.Mkdir(projectPath, 0755) 140 | require.NoError(t, err) 141 | f, err := os.Create(projectPath + "/README.md") 142 | require.NoError(t, err) 143 | f.Close() 144 | 145 | err = db.WriteToDB(dbname, map[string]string{alias: projectName}, c.ProjectAliasBucket) 146 | require.NoError(t, err) 147 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 148 | require.NoError(t, err) 149 | 150 | actual, err := utils.ReadREADME(dbname, alias) 151 | 152 | assert.Equal(t, expected, actual) 153 | require.NoError(t, err) 154 | }) 155 | t.Run("Test ReadREADME with well alias and bad project", func(t *testing.T) { 156 | alias := "project_alias" 157 | expected := []byte(nil) 158 | 159 | t.Cleanup(func() { 160 | err := db.DeleteDb(dbname) 161 | require.NoError(t, err) 162 | _ = os.RemoveAll(projectPath) 163 | }) 164 | 165 | err := db.WriteToDB(dbname, map[string]string{alias: projectName}, c.ProjectAliasBucket) 166 | require.NoError(t, err) 167 | 168 | actual, err := utils.ReadREADME(dbname, alias) 169 | 170 | assert.Equal(t, expected, actual) 171 | require.ErrorIs(t, err, utils.ErrGetProject) 172 | }) 173 | 174 | t.Run("Test ReadREADME with empty project name", func(t *testing.T) { 175 | projectName := "" 176 | expected := []byte(nil) 177 | 178 | actual, err := utils.ReadREADME(dbname, projectName) 179 | 180 | assert.Equal(t, expected, actual) 181 | require.ErrorIs(t, err, utils.ErrGetAlias) 182 | }) 183 | 184 | t.Run("Test ReadREADME with invalid README file name", func(t *testing.T) { 185 | expected := []byte(fmt.Sprintf("# README does not exist for %s", projectName)) 186 | 187 | t.Cleanup(func() { 188 | err := db.DeleteDb(dbname) 189 | require.NoError(t, err) 190 | _ = os.RemoveAll(projectPath) 191 | }) 192 | 193 | err := os.Mkdir(projectPath, 0755) 194 | require.NoError(t, err) 195 | f, err := os.Create(projectPath + "/README.txt") 196 | require.NoError(t, err) 197 | f.Close() 198 | 199 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 200 | require.NoError(t, err) 201 | 202 | actual, err := utils.ReadREADME(dbname, projectName) 203 | 204 | assert.Equal(t, expected, actual) 205 | require.NoError(t, err) 206 | }) 207 | } 208 | 209 | func Test_GetLastModifiedTime(t *testing.T) { 210 | t.Run("Test GetLastModifiedTime under normal conditions: case Today modification", func(t *testing.T) { 211 | t.Cleanup(func() { 212 | err := db.DeleteDb(dbname) 213 | require.NoError(t, err) 214 | _ = os.RemoveAll(projectPath) 215 | }) 216 | 217 | err := os.Mkdir(projectPath, 0755) 218 | require.NoError(t, err) 219 | f, err := os.Create(projectPath + "/README.md") 220 | require.NoError(t, err) 221 | fCreateTime := time.Now() 222 | correctModTime := fCreateTime.Format("02 Jan 06 15:04") 223 | err = os.Chtimes(projectPath+"/README.md", fCreateTime, fCreateTime) 224 | require.NoError(t, err) 225 | f.Close() 226 | 227 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 228 | require.NoError(t, err) 229 | 230 | actual := utils.GetLastModifiedTime(dbname, projectName) 231 | 232 | assert.NotEqual(t, expectedMsg, actual) 233 | assert.Equal(t, correctModTime, actual) 234 | assert.NotEmpty(t, actual) 235 | }) 236 | 237 | t.Run("Test GetLastModifiedTime under normal conditions: case Yesterday modification", func(t *testing.T) { 238 | t.Cleanup(func() { 239 | err := db.DeleteDb(dbname) 240 | require.NoError(t, err) 241 | _ = os.RemoveAll(projectPath) 242 | }) 243 | 244 | err := os.Mkdir(projectPath, 0755) 245 | require.NoError(t, err) 246 | f, err := os.Create(projectPath + "/README.md") 247 | require.NoError(t, err) 248 | fCreateTime := time.Now().AddDate(0, 0, -1) 249 | correctModTime := fCreateTime.Format("02 Jan 06 15:04") 250 | err = os.Chtimes(projectPath+"/README.md", fCreateTime, fCreateTime) 251 | require.NoError(t, err) 252 | f.Close() 253 | 254 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 255 | require.NoError(t, err) 256 | 257 | actual := utils.GetLastModifiedTime(dbname, projectName) 258 | 259 | assert.NotEqual(t, expectedMsg, actual) 260 | assert.Equal(t, correctModTime, actual) 261 | assert.NotEmpty(t, actual) 262 | }) 263 | t.Run("Test GetLastModifiedTime under normal conditions: case old modification", func(t *testing.T) { 264 | t.Cleanup(func() { 265 | err := db.DeleteDb(dbname) 266 | require.NoError(t, err) 267 | _ = os.RemoveAll(projectPath) 268 | }) 269 | 270 | err := os.Mkdir(projectPath, 0755) 271 | require.NoError(t, err) 272 | f, err := os.Create(projectPath + "/README.md") 273 | require.NoError(t, err) 274 | err = os.Chtimes(projectPath+"/README.md", time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -5)) 275 | require.NoError(t, err) 276 | f.Close() 277 | 278 | err = db.WriteToDB(dbname, map[string]string{projectName: projectPath}, c.ProjectPaths) 279 | require.NoError(t, err) 280 | 281 | actual := utils.GetLastModifiedTime(dbname, projectName) 282 | 283 | assert.NotEqual(t, expectedMsg, actual) 284 | assert.NotEmpty(t, actual) 285 | }) 286 | t.Run("Test GetLastModifiedTime with invalid project", func(t *testing.T) { 287 | projectPath := "./invalid_project" 288 | 289 | actual := utils.GetLastModifiedTime(dbname, projectPath) 290 | 291 | assert.Equal(t, expectedMsg, actual) 292 | }) 293 | } 294 | --------------------------------------------------------------------------------