diff --git a/cmd/brag/delete/delete.go b/cmd/brag/delete/delete.go index ee3ab53..4e7ffd2 100644 --- a/cmd/brag/delete/delete.go +++ b/cmd/brag/delete/delete.go @@ -1,16 +1,14 @@ package delete import ( - "cronicle/utils" - "fmt" - "strconv" + "cronicle/utils/entries" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "delete [ID!]", + Use: "delete ", Short: "delete a brag entry", Long: "delete a brag entry", Run: run, @@ -20,14 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("brag") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - utils.DeleteFile(files[n-1].Name(), "brag") - utils.ListFiles("brag") + entries.DeleteEntry(args, "brag") } diff --git a/cmd/brag/update/update.go b/cmd/brag/update/update.go index 008abf9..b6f360c 100644 --- a/cmd/brag/update/update.go +++ b/cmd/brag/update/update.go @@ -1,9 +1,7 @@ package update import ( - "cronicle/utils" - "fmt" - "strconv" + "cronicle/utils/entries" "github.com/spf13/cobra" ) @@ -20,15 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("brag") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - path := utils.GetPath([]string{"brag", files[n-1].Name()}) - - utils.EditFile(path) + entries.EditEntry(args, "brag") } diff --git a/cmd/daily/delete/delete.go b/cmd/daily/delete/delete.go index 8127111..aa3c718 100644 --- a/cmd/daily/delete/delete.go +++ b/cmd/daily/delete/delete.go @@ -1,16 +1,14 @@ package delete import ( - "cronicle/utils" - "fmt" - "strconv" + "cronicle/utils/entries" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "delete [ID!]", + Use: "delete ", Short: "delete a daily file", Long: "delete a daily file", Run: run, @@ -20,14 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("daily") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - utils.DeleteFile(files[n-1].Name(), "daily") - utils.ListFiles("daily") + entries.DeleteEntry(args, "daily") } diff --git a/cmd/daily/update/update.go b/cmd/daily/update/update.go index b60687c..5d8a096 100644 --- a/cmd/daily/update/update.go +++ b/cmd/daily/update/update.go @@ -1,16 +1,14 @@ package update import ( - "cronicle/utils" - "fmt" - "strconv" + "cronicle/utils/entries" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "update [ID!]", + Use: "update ", Short: "update a daily entry", Long: "update a daily entry", Run: run, @@ -20,15 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("daily") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - path := utils.GetPath([]string{"daily", files[n-1].Name()}) - - utils.EditFile(path) + entries.EditEntry(args, "daily") } diff --git a/cmd/todo/complete/complete.go b/cmd/todo/complete/complete.go index cb7d1c1..6954f52 100644 --- a/cmd/todo/complete/complete.go +++ b/cmd/todo/complete/complete.go @@ -1,17 +1,14 @@ package complete import ( - "cronicle/utils" "cronicle/utils/todo" - "fmt" - "strconv" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "complete [ID!]", + Use: "complete ", Short: "complete a todo entry", Long: "complete a todo entry", Run: run, @@ -21,14 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("todo") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - todo.MarkCompleted(files[n-1]) - todo.ListTodos() + todo.CompleteTodo() } diff --git a/cmd/todo/delete/delete.go b/cmd/todo/delete/delete.go index 47be9eb..a682acd 100644 --- a/cmd/todo/delete/delete.go +++ b/cmd/todo/delete/delete.go @@ -1,17 +1,14 @@ package delete import ( - "cronicle/utils" - "cronicle/utils/todo" - "fmt" - "strconv" + "cronicle/utils/entries" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "delete [ID!]", + Use: "delete ", Short: "delete a todo entry", Long: "delete a todo entry", Run: run, @@ -21,15 +18,5 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - - files := utils.GetAllFiles("todo") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - utils.DeleteFile(files[n-1].Name(), "todo") - todo.ListTodos() + entries.DeleteEntry(args, "todo") } diff --git a/cmd/todo/update/update.go b/cmd/todo/update/update.go index e4502ca..a7ee0bf 100644 --- a/cmd/todo/update/update.go +++ b/cmd/todo/update/update.go @@ -1,16 +1,15 @@ package update import ( - "cronicle/utils" - "fmt" - "strconv" + "cronicle/utils/entries" + "log" "github.com/spf13/cobra" ) func New() *cobra.Command { cmd := &cobra.Command{ - Use: "update [ID!]", + Use: "update ", Short: "update a todo entry", Long: "update a todo entry", Run: run, @@ -20,15 +19,6 @@ func New() *cobra.Command { } func run(cmd *cobra.Command, args []string) { - files := utils.GetAllFiles("todo") - - n, err := strconv.Atoi(args[0]) - if err != nil || n == 0 || n > len(files) { - fmt.Printf("Invalid argument") - return - } - - path := utils.GetPath([]string{"todo", files[n-1].Name()}) - - utils.EditFile(path) + log.Println(args) + entries.EditEntry(args, "todo") } diff --git a/go.mod b/go.mod index 0a857a6..41a4405 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,8 @@ require ( github.com/charmbracelet/lipgloss v0.5.0 github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f // indirect - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.3.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 // indirect github.com/sergi/go-diff v1.0.0 // indirect github.com/spf13/cobra v1.3.0 diff --git a/go.sum b/go.sum index 4cd87f0..c3a1e08 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,7 @@ github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5R github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= @@ -284,6 +285,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -541,6 +544,7 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/ui/constants/constants.go b/ui/constants/constants.go index a9cd875..2867182 100644 --- a/ui/constants/constants.go +++ b/ui/constants/constants.go @@ -20,4 +20,8 @@ const ( ERROR_CLOSE_FILE = "Darn, error closing file: %w" ERROR_LIST_FILE = "Darn, error listing files: %w" ERROR_DELETE_FILE = "Darn, error deleting file: %w" + ERROR_PROMPT = "Darn, error with prompt: %w" ) + +const MaxLengthDisplayOption = 20 +const MaxLengthDetails = 50 diff --git a/utils/entries/entries.go b/utils/entries/entries.go index 8d3426c..0ee890f 100644 --- a/utils/entries/entries.go +++ b/utils/entries/entries.go @@ -3,8 +3,10 @@ package entries import ( "cronicle/ui/constants" "cronicle/utils" + "cronicle/utils/prompts" "errors" "fmt" + "io/fs" "log" "os" "strings" @@ -85,3 +87,77 @@ func composeEntry(w utils.WriteParams, t string) string { return output.String() } + +func EditEntry(args []string, t string) { + files := utils.GetAllFiles(t) + var id int + + if len(args) > 0 { + // user has given an arguement + argId := utils.GetIdFromArg(args, files) + if argId == -1 { + fmt.Printf("Invalid argument") + return + } + id = argId + } else { + // user selects from options + id = GetIdFromOptions(files, t, "update") + } + + path := utils.GetPath([]string{t, files[id].Name()}) + + utils.EditFile(path) +} + +func DeleteEntry(args []string, t string) { + files := utils.GetAllFiles(t) + var id int + + if len(args) > 0 { + // user has given an arguement + argId := utils.GetIdFromArg(args, files) + if argId == -1 { + fmt.Printf("Invalid argument") + return + } + id = argId + } else { + // user selects from options + id = GetIdFromOptions(files, t, "delete") + } + + utils.DeleteFile(files[id].Name(), t) +} + +func GetIdFromOptions(files []fs.FileInfo, t string, action string) int { + var id int + if t == "todo" { + id = GetIdFromTodoOptions(files, t, action) + } else { + id = GetIdFromEntryOptions(files, t, action) + } + return id +} + +func GetIdFromEntryOptions(files []fs.FileInfo, t string, action string) int { + options := prompts.GetEntryDisplayOptions(t, files) + i, err := prompts.SelectEntry(options, action) + + if err != nil { + log.Fatal(constants.ERROR_PROMPT, err) + } + + return i +} + +func GetIdFromTodoOptions(files []fs.FileInfo, t string, action string) int { + options := prompts.GetTodoDisplayOptions(files) + i, err := prompts.SelectTodo(options, action) + + if err != nil { + log.Fatal(constants.ERROR_PROMPT, err) + } + + return i +} diff --git a/utils/file.go b/utils/file.go index d244d29..7baa4b1 100644 --- a/utils/file.go +++ b/utils/file.go @@ -2,6 +2,7 @@ package utils import ( "cronicle/ui/constants" + "cronicle/utils/types" "fmt" "io/fs" "io/ioutil" @@ -25,7 +26,10 @@ func GetDataFromFile(path string) string { func GetAllFiles(d string) []fs.FileInfo { p := GetPath([]string{d}) - f, _ := ioutil.ReadDir(p) + f, err := ioutil.ReadDir(p) + if err != nil { + log.Fatal(constants.ERROR_LIST_FILE, err) + } return f } @@ -73,7 +77,7 @@ func ListFiles(t string) { } for i, f := range files { - fmt.Printf("%v. %s", i+1, f.Name()) + fmt.Printf("%v. %s\n", i+1, f.Name()) } } @@ -94,15 +98,8 @@ func ParseContent(content string) string { return c } -type Header struct { - Date string `yaml:"date"` - Due string `yaml:"due"` - Type string `yaml:"type"` - Tags []string `yaml:"tags"` -} - -func ParseHeader(content string) Header { - var matter Header +func ParseHeader(content string) types.Header { + var matter types.Header _, err := frontmatter.Parse(strings.NewReader(content), &matter) if err != nil { diff --git a/utils/prompts/prompts.go b/utils/prompts/prompts.go new file mode 100644 index 0000000..7b3ccf6 --- /dev/null +++ b/utils/prompts/prompts.go @@ -0,0 +1,124 @@ +package prompts + +import ( + "cronicle/ui/constants" + "cronicle/utils" + "cronicle/utils/types" + "fmt" + "io/fs" + "log" + "strings" + + "github.com/adrg/frontmatter" + "github.com/manifoldco/promptui" +) + +func SelectTodo(todoOptions []types.TodoProperties, action string) (int, error) { + + templates := &promptui.SelectTemplates{ + Label: "{{ . | bold }}", + Active: "\U00002192 {{ .Todo | cyan }}", + Inactive: "{{ .Todo | cyan }}", + Selected: "\U00002714 {{ .Todo | green | cyan }}", + Details: ` + --------- Properties ---------- + {{ "Date:" | faint }} {{ .Date }} + {{ "Tags:" | faint }} {{ .Tags }} + {{ "Due:" | faint }} {{ .Due }} + {{ "Todo:" | faint }} {{ .TodoDetails }}`, + } + + searcher := func(input string, index int) bool { + todo := todoOptions[index] + name := strings.Replace(strings.ToLower(todo.Todo), " ", "", -1) + input = strings.Replace(strings.ToLower(input), " ", "", -1) + + return strings.Contains(name, input) + } + + prompt := promptui.Select{ + Label: fmt.Sprintf("Select todo to %s:", action), + Items: todoOptions, + Templates: templates, + Searcher: searcher, + } + + id, _, err := prompt.Run() + + return id, err +} + +func SelectEntry(options []types.EntryProperties, action string) (int, error) { + + templates := &promptui.SelectTemplates{ + Label: "{{ . | bold }}", + Active: "\U00002192 {{ .FileName | cyan }}", + Inactive: "{{ .FileName | cyan }}", + Selected: "\U00002714 {{ .FileName | green | cyan }}", + Details: ` + --------- Properties ---------- + {{ "Date:" | faint }} {{ .Date }} + {{ "Tags:" | faint }} {{ .Tags }} + {{ "Entry:" | faint }} {{ .EntryDetails }}`, + } + + searcher := func(input string, index int) bool { + entry := options[index] + name := strings.Replace(strings.ToLower(entry.Entry), " ", "", -1) + input = strings.Replace(strings.ToLower(input), " ", "", -1) + + return strings.Contains(name, input) + } + + prompt := promptui.Select{ + Label: fmt.Sprintf("Select entry to %s:", action), + Items: options, + Templates: templates, + Searcher: searcher, + } + + id, _, err := prompt.Run() + + return id, err +} + +func GetEntryDisplayOptions(t string, files []fs.FileInfo) []types.EntryProperties { + var matter types.EntryProperties + var options []types.EntryProperties + + for _, f := range files { + path := utils.GetPath([]string{t, f.Name()}) + content := utils.GetDataFromFile(path) + rest, err := frontmatter.Parse(strings.NewReader(content), &matter) + if err != nil { + log.Println(err) + } + a := strings.Split(string(rest), "\n") + b := strings.Join(a, ",") + matter.Entry = utils.TruncateText(string(rest)[6:], constants.MaxLengthDisplayOption) + matter.EntryDetails = utils.TruncateText(b, constants.MaxLengthDetails) + matter.FileName = f.Name() + options = append(options, matter) + } + + return options +} + +func GetTodoDisplayOptions(files []fs.FileInfo) []types.TodoProperties { + var matter types.TodoProperties + var options []types.TodoProperties + + for _, f := range files { + path := utils.GetPath([]string{"todo", f.Name()}) + content := utils.GetDataFromFile(path) + rest, err := frontmatter.Parse(strings.NewReader(content), &matter) + if err != nil { + log.Println(err) + } + matter.Todo = utils.TruncateText(string(rest)[6:], constants.MaxLengthDisplayOption) + matter.TodoDetails = utils.TruncateText(string(rest)[6:], constants.MaxLengthDetails) + options = append(options, matter) + } + + return options +} diff --git a/utils/todo/todo.go b/utils/todo/todo.go index 6416750..ee1bea8 100644 --- a/utils/todo/todo.go +++ b/utils/todo/todo.go @@ -4,9 +4,9 @@ import ( "cronicle/ui/constants" "cronicle/utils" "cronicle/utils/entries" + "cronicle/utils/prompts" "fmt" "io/fs" - "io/ioutil" "log" "strings" "time" @@ -47,17 +47,6 @@ func ComposeTodo(w utils.WriteParams) string { return output.String() } -func MarkCompleted(f fs.FileInfo) { - //add todo list to log - path := utils.GetPath([]string{"todo", f.Name()}) - todo := utils.GetDataFromFile(path) - checkedTodo := CheckTodo(todo) - tags := utils.ParseHeader(todo).Tags - //add completed todo to log - entries.WriteOrCreateEntry(utils.WriteParams{Message: checkedTodo, Tags: strings.Join(tags, ",")}, "daily") - utils.DeleteFile(f.Name(), "todo") -} - func CheckTodo(todo string) string { m := utils.ParseContent(todo) @@ -73,30 +62,14 @@ func CheckTodo(todo string) string { return c.String() } -func GetTodoFilePaths() []fs.FileInfo { - p := utils.GetPath([]string{"todo"}) - - f, err := ioutil.ReadDir(p) - if err != nil { - log.Fatal(constants.ERROR_LIST_FILE, err) - } - - return f -} - -func GetTodoFromFile(fileName string) string { - path := utils.GetPath([]string{"todo", fileName}) - todo := utils.GetDataFromFile(path) - return todo -} - func GetAllTodos() []string { var todos []string - files := GetTodoFilePaths() + files := utils.GetAllFiles("todo") for _, f := range files { - todo := GetTodoFromFile(f.Name()) + path := utils.GetPath([]string{"todo", f.Name()}) + todo := utils.GetDataFromFile(path) todos = append(todos, utils.ParseContent(todo)) } @@ -104,12 +77,36 @@ func GetAllTodos() []string { } func ListTodos() { - files := GetTodoFilePaths() + files := utils.GetAllFiles("todo") for i, f := range files { - todo := GetTodoFromFile(f.Name()) + path := utils.GetPath([]string{"todo", f.Name()}) + todo := utils.GetDataFromFile(path) task := utils.ParseContent(todo) fmt.Printf("%v. %s", i+1, task[6:]) } } + +func CompleteTodo() { + files := utils.GetAllFiles("todo") + todoOptions := prompts.GetTodoDisplayOptions(files) + id, err := prompts.SelectTodo(todoOptions, "complete") + + if err != nil { + log.Fatal(constants.ERROR_PROMPT, err) + } + + MarkCompleted(files[id]) +} + +func MarkCompleted(f fs.FileInfo) { + //add todo list to log + path := utils.GetPath([]string{"todo", f.Name()}) + todo := utils.GetDataFromFile(path) + checkedTodo := CheckTodo(todo) + tags := utils.ParseHeader(todo).Tags + //add completed todo to log + entries.WriteOrCreateEntry(utils.WriteParams{Message: checkedTodo, Tags: strings.Join(tags, ",")}, "daily") + utils.DeleteFile(f.Name(), "todo") +} diff --git a/utils/types/types.go b/utils/types/types.go new file mode 100644 index 0000000..9d83210 --- /dev/null +++ b/utils/types/types.go @@ -0,0 +1,24 @@ +package types + +type Header struct { + Date string `yaml:"date"` + Due string `yaml:"due"` + Type string `yaml:"type"` + Tags []string `yaml:"tags"` +} + +type TodoProperties struct { + Date string `yaml:"date"` + Due string `yaml:"due"` + Tags []string `yaml:"tags"` + Todo string + TodoDetails string +} + +type EntryProperties struct { + Date string `yaml:"date"` + Tags []string `yaml:"tags"` + Entry string + EntryDetails string + FileName string +} diff --git a/utils/utils.go b/utils/utils.go index 3b64fe4..b673481 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,5 +1,11 @@ package utils +import ( + "io/fs" + "strconv" + "strings" +) + func Max(a, b int) int { if a > b { return a @@ -26,3 +32,25 @@ func Contains(s []string, e string) bool { } return false } + +func TruncateText(s string, max int) string { + t := strings.TrimSpace(s) + + if max > len(t) { + return t + } + + if strings.LastIndex(s[:max], " ") == -1 { + return s[:max] + } + + return s[:strings.LastIndex(s[:max], " ")] + "..." +} + +func GetIdFromArg(args []string, files []fs.FileInfo) int { + n, err := strconv.Atoi(args[0]) + if err != nil || n == 0 || n > len(files) { + return -1 + } + return n - 1 +}