From baf991022c999bf53fc663f8bfb4096501d74db3 Mon Sep 17 00:00:00 2001 From: Munish Goyal Date: Mon, 9 Jan 2023 21:56:09 +1100 Subject: [PATCH] Some refactoring and Add coverage to Makefile --- Makefile | 8 + cmd/reminder/main.go | 5 +- go.mod | 8 +- go.sum | 16 +- internal/model/functions.go | 8 +- internal/model/model_test.go | 77 ++++- internal/model/note.go | 26 +- internal/model/notes.go | 2 +- internal/model/reminder_data.go | 28 +- pkg/calendar/calendar.go | 104 +++--- pkg/utils/functions.go | 443 +++++------------------- pkg/utils/functions_test.go | 577 ++++++++++++++++++++++++-------- pkg/utils/survey.go | 125 +++++++ pkg/utils/survey_test.go | 1 + pkg/utils/time.go | 173 ++++++++++ pkg/utils/time_test.go | 306 +++++++++++++++++ pkg/utils/utils_linker_test.go | 7 + scripts/go_coverage | 8 + scripts/go_test | 2 +- scripts/open_data_file | 3 + 20 files changed, 1345 insertions(+), 582 deletions(-) create mode 100644 pkg/utils/survey.go create mode 100644 pkg/utils/survey_test.go create mode 100644 pkg/utils/time.go create mode 100644 pkg/utils/time_test.go create mode 100644 pkg/utils/utils_linker_test.go create mode 100644 scripts/go_coverage create mode 100644 scripts/open_data_file diff --git a/Makefile b/Makefile index 46bde1e..6917e42 100644 --- a/Makefile +++ b/Makefile @@ -22,3 +22,11 @@ fmt: .PHONY: test test: . ./scripts/go_test + +.PHONY: coverage +coverage: + . ./scripts/go_coverage + +.PHONY: open +open: + . ./scripts/open_data_file diff --git a/cmd/reminder/main.go b/cmd/reminder/main.go index 1ff0af3..d60482b 100644 --- a/cmd/reminder/main.go +++ b/cmd/reminder/main.go @@ -48,7 +48,10 @@ func Run() error { // check if the data file is locked by another session if reminderData.MutexLock { fmt.Printf("WARNING! %s\n", model.ErrorMutexLockOn.Error()) - reset := utils.AskBoolean("But, do you want to force reset the lock?") + reset, err := utils.AskBoolean("But, do you want to force reset the lock?") + if err != nil { + return err + } if !reset { // exit now without resetting the lock return model.ErrorMutexLockOn diff --git a/go.mod b/go.mod index 4af48af..fd60cbc 100644 --- a/go.mod +++ b/go.mod @@ -41,10 +41,10 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.1 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/net v0.3.0 // indirect - golang.org/x/sys v0.3.0 // indirect - golang.org/x/term v0.3.0 // indirect - golang.org/x/text v0.5.0 // indirect + golang.org/x/net v0.5.0 // indirect + golang.org/x/sys v0.4.0 // indirect + golang.org/x/term v0.4.0 // indirect + golang.org/x/text v0.6.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20221206210731-b1a01be3a5f6 // indirect google.golang.org/grpc v1.51.0 // indirect diff --git a/go.sum b/go.sum index 52cffa4..6b1a48b 100644 --- a/go.sum +++ b/go.sum @@ -306,8 +306,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.3.0 h1:VWL6FNY2bEEmsGVKabSlHu5Irp34xmMRoqb/9lF9lxk= -golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -370,13 +370,13 @@ golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= -golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -384,8 +384,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/model/functions.go b/internal/model/functions.go index 925c5d4..14ef070 100644 --- a/internal/model/functions.go +++ b/internal/model/functions.go @@ -88,11 +88,11 @@ func NewNote(tagIDs []int, useText string) (*Note, error) { } else { noteText = useText } - note.Text = utils.TrimString(noteText) + note.Text = strings.TrimSpace(noteText) if err != nil || strings.Contains(note.Text, "^C") { return note, err } - if len(utils.TrimString(note.Text)) == 0 { + if len(strings.TrimSpace(note.Text)) == 0 { // this should never be encountered because of validation in earlier step fmt.Printf("%v Skipping adding note with empty text\n", utils.Symbols["warning"]) return note, errors.New("Note's text is empty") @@ -153,9 +153,9 @@ func NewTag(tagID int, useSlug string, useGroup string) (*Tag, error) { } else { tagSlug = useSlug } - tag.Slug = utils.TrimString(tagSlug) + tag.Slug = strings.TrimSpace(tagSlug) tag.Slug = strings.ToLower(tag.Slug) - if len(utils.TrimString(tag.Slug)) == 0 { + if len(strings.TrimSpace(tag.Slug)) == 0 { // this should never be encountered because of validation in earlier step fmt.Printf("%v Skipping adding tag with empty slug\n", utils.Symbols["warning"]) err := errors.New("Tag's slug is empty") diff --git a/internal/model/model_test.go b/internal/model/model_test.go index ee403e0..35aaa35 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -2,6 +2,7 @@ package model_test import ( "errors" + "fmt" // "fmt" "io/fs" @@ -17,6 +18,7 @@ import ( model "github.com/goyalmunish/reminder/internal/model" "github.com/goyalmunish/reminder/internal/settings" utils "github.com/goyalmunish/reminder/pkg/utils" + gc "google.golang.org/api/calendar/v3" ) // mocks @@ -27,6 +29,16 @@ type MockPromptTagGroup struct { } type MockPromptNoteText struct { } +type TestTagger struct{} + +func (tagger TestTagger) TagsFromIds(tagIDs []int) []string { + slugs := []string{} + for i, id := range tagIDs { + slug := fmt.Sprintf("%d-%d", i, id) + slugs = append(slugs, slug) + } + return slugs +} func (prompt *MockPromptTagSlug) Run() (string, error) { return "test_tag_slug", nil @@ -380,6 +392,26 @@ func TestWithCompleteBy(t *testing.T) { utils.AssertEqual(t, got, want) } +func TestOnlyMain(t *testing.T) { + var notes model.Notes + // case 1 (no notes) + utils.AssertEqual(t, notes.OnlyMain(), model.Notes{}) + // add some notes + comments := model.Comments{&model.Comment{Text: "c1"}} + note1 := model.Note{Text: "big fat cat", Comments: comments, Status: model.NoteStatus_Pending, TagIds: []int{1, 2}, CompleteBy: 1609669231} + notes = append(notes, ¬e1) + comments = model.Comments{&model.Comment{Text: "c1"}, &model.Comment{Text: "foo bar"}} + note2 := model.Note{Text: "cute brown dog", Comments: comments, Status: model.NoteStatus_Done, TagIds: []int{1, 3}, IsMain: true, CompleteBy: 1609669232} + notes = append(notes, ¬e2) + comments = model.Comments{&model.Comment{Text: "foo bar"}, &model.Comment{Text: "c3"}} + note3 := model.Note{Text: "little hamster", Comments: comments, Status: model.NoteStatus_Pending, TagIds: []int{1}} + notes = append(notes, ¬e3) + // case 3 (with only few notes to be filtered in) + got := notes.OnlyMain() + want := model.Notes{¬e2} + utils.AssertEqual(t, got, want) +} + func TestWithTagIdAndStatus(t *testing.T) { // var tags model.Tags var notes model.Notes @@ -551,6 +583,45 @@ func TestToggleMainFlag(t *testing.T) { utils.AssertEqual(t, originalPriority != note1.IsMain, true) } +func TestGoogleCalendarEvent(t *testing.T) { + tagger := TestTagger{} + var tests = []struct { + name string // has to be string + note model.Note + inputRATID int + inputRMTID int + inputTimezone string + inputTagger model.Tagger + want *gc.Event + wantErr error + wantedErr bool + }{ + { + name: "general case 1", + note: model.Note{Text: "original text", Status: model.NoteStatus_Pending, TagIds: []int{1, 4}, BaseStruct: model.BaseStruct{UpdatedAt: 1600000001}}, + inputRATID: 1, + inputRMTID: 3, + inputTimezone: "Australia/Melbourne", + inputTagger: tagger, + want: &gc.Event{ + Summary: "[reminder] original text", + }, + wantedErr: false, + }, + } + for position, subtest := range tests { + t.Run(subtest.name, func(t *testing.T) { + got, err := subtest.note.GoogleCalendarEvent(subtest.inputRATID, subtest.inputRMTID, subtest.inputTimezone, tagger) + if (err != nil) != subtest.wantedErr { + t.Fatalf("GoogleCalendarEvent case %q (position=%d) with input <%+v> returns error <%v>; wantError <%v>", subtest.name, position, subtest.note, err, subtest.wantErr) + } + if got.Summary != subtest.want.Summary { + t.Errorf("GoogleCalendarEvent case %q (position=%d) with input <%+v> returns <%+v>; want <%+v>", subtest.name, position, subtest.note, got, subtest.want) + } + }) + } +} + func TestSortedTagsSlug(t *testing.T) { reminderData := model.ReminderData{ User: &model.User{Name: "Test User", EmailId: "user@test.com"}, @@ -592,17 +663,17 @@ func TestTagsFromIds(t *testing.T) { // case 1 tagIDs := []int{1, 3} gotSlugs := reminderData.TagsFromIds(tagIDs) - wantSlugs := model.Tags{&tag1, &tag3} + wantSlugs := model.Tags{&tag1, &tag3}.Slugs() utils.AssertEqual(t, gotSlugs, wantSlugs) // case 2 tagIDs = []int{} gotSlugs = reminderData.TagsFromIds(tagIDs) - wantSlugs = model.Tags{} + wantSlugs = model.Tags{}.Slugs() utils.AssertEqual(t, gotSlugs, wantSlugs) // case 3 tagIDs = []int{1, 4, 2, 3} gotSlugs = reminderData.TagsFromIds(tagIDs) - wantSlugs = model.Tags{&tag1, &tag4, &tag2, &tag3} + wantSlugs = model.Tags{&tag1, &tag4, &tag2, &tag3}.Slugs() utils.AssertEqual(t, gotSlugs, wantSlugs) } diff --git a/internal/model/note.go b/internal/model/note.go index b909f6e..79319e6 100644 --- a/internal/model/note.go +++ b/internal/model/note.go @@ -74,7 +74,7 @@ func (note *Note) Strings() ([]string, error) { } // externalText returns a note with its tags slugs as a slice of strings. -func (note *Note) externalText(reminderData *ReminderData) ([]string, error) { +func (note *Note) externalText(tagger Tagger) ([]string, error) { var strs []string strs = append(strs, fmt.Sprintln("Note Details: -------------------------------------------------")) basicStrs, err := note.Strings() @@ -82,7 +82,7 @@ func (note *Note) externalText(reminderData *ReminderData) ([]string, error) { return nil, err } // replace tag ids with tag slugs - tagsStr := printNoteField("Tags", reminderData.TagsFromIds(note.TagIds).Slugs()) + tagsStr := printNoteField("Tags", tagger.TagsFromIds(note.TagIds)) basicStrs[4] = tagsStr // create final list of strings strs = append(strs, basicStrs...) @@ -101,8 +101,8 @@ func (note *Note) ExternalText(reminderData *ReminderData) (string, error) { // SafeExtText prints a note with its tags slugs, but only the safe components. // This is used as final external reprensentation for display of a single note to external services like Google Calendar. -func (note *Note) SafeExtText(reminderData *ReminderData) (string, error) { - strs, err := note.externalText(reminderData) +func (note *Note) SafeExtText(tagger Tagger) (string, error) { + strs, err := note.externalText(tagger) if err != nil { return "", err } @@ -146,7 +146,7 @@ func (note *Note) SearchableText() (string, error) { // AddComment adds a new comment to note. func (note *Note) AddComment(text string) error { - if len(utils.TrimString(text)) == 0 { + if len(strings.TrimSpace(text)) == 0 { return errors.New("Note's comment text is empty") } comment := &Comment{Text: text, BaseStruct: BaseStruct{CreatedAt: utils.CurrentUnixTimestamp()}} @@ -169,7 +169,7 @@ func (note *Note) UpdateTags(tagIDs []int) error { // UpdateStatus updates note's status ("done"/"pending"). // Status of a note tag with repeat tag cannot be mared as "done". func (note *Note) UpdateStatus(status NoteStatus, repeatTagIDs []int) error { - noteIDsWithRepeat := utils.GetCommonMembersIntSlices(note.TagIds, repeatTagIDs) + noteIDsWithRepeat := utils.GetCommonMembersOfSlices(note.TagIds, repeatTagIDs) if len(noteIDsWithRepeat) != 0 { return errors.New("Note is part of a \"repeat\" group") } @@ -187,7 +187,7 @@ func (note *Note) UpdateStatus(status NoteStatus, repeatTagIDs []int) error { // UpdateText updates note's text. // Once updated, the text cannot be made empty. func (note *Note) UpdateText(text string) error { - if len(utils.TrimString(text)) == 0 { + if len(strings.TrimSpace(text)) == 0 { return errors.New("Note's text is empty") } // happy path @@ -201,7 +201,7 @@ func (note *Note) UpdateText(text string) error { // UpdateSummary updates note's summary. // If input is "nil", the existing summary is cleared. func (note *Note) UpdateSummary(text string) error { - if len(utils.TrimString(text)) == 0 { + if len(strings.TrimSpace(text)) == 0 { return errors.New("Note's summary is empty") } // happy path @@ -222,7 +222,7 @@ func (note *Note) UpdateSummary(text string) error { // If input is "nil", the existing due date is cleared. func (note *Note) UpdateCompleteBy(text string) error { // handle edge-case of empty text - if len(utils.TrimString(text)) == 0 { + if len(strings.TrimSpace(text)) == 0 { return errors.New("Note's due date is empty") } // happy path @@ -255,9 +255,9 @@ func (note *Note) UpdateCompleteBy(text string) error { // representing repeat-type of the note func (note *Note) RepeatType(repeatAnnuallyTagId int, repeatMonthlyTagId int) string { repeat := "-" // non-repeat - if utils.IntPresentInSlice(repeatAnnuallyTagId, note.TagIds) { + if utils.IsMemberOfSlice(repeatAnnuallyTagId, note.TagIds) { repeat = "A" - } else if utils.IntPresentInSlice(repeatMonthlyTagId, note.TagIds) { + } else if utils.IsMemberOfSlice(repeatMonthlyTagId, note.TagIds) { repeat = "M" } return repeat @@ -273,7 +273,7 @@ func (note *Note) ToggleMainFlag() error { } // GoogleCalendarEvent converts a note to Google Calendar Event. -func (note *Note) GoogleCalendarEvent(repeatAnnuallyTagId int, repeatMonthlyTagId int, timezoneIANA string, reminderData *ReminderData) (*gc.Event, error) { +func (note *Note) GoogleCalendarEvent(repeatAnnuallyTagId int, repeatMonthlyTagId int, timezoneIANA string, tagger Tagger) (*gc.Event, error) { // basic information title := note.Text start := utils.UnixTimestampToTime(note.CompleteBy) // this is the original time in 00:00:00 GMT+0000 @@ -284,7 +284,7 @@ func (note *Note) GoogleCalendarEvent(repeatAnnuallyTagId int, repeatMonthlyTagI start = start.Add(offset) // adjusting the start to local time for notification purpose start = start.Add(-14 * time.Hour) // set notification for 10 AM of given timezoneIANA repeatType := note.RepeatType(repeatAnnuallyTagId, repeatMonthlyTagId) - description, err := note.SafeExtText(reminderData) + description, err := note.SafeExtText(tagger) if err != nil { return nil, err } diff --git a/internal/model/notes.go b/internal/model/notes.go index 979324b..9788e85 100644 --- a/internal/model/notes.go +++ b/internal/model/notes.go @@ -83,7 +83,7 @@ func (notes Notes) WithTagIdAndStatus(tagID int, status NoteStatus) Notes { notesWithStatus := notes.WithStatus(status) var result Notes for _, note := range notesWithStatus { - if utils.IntPresentInSlice(tagID, note.TagIds) { + if utils.IsMemberOfSlice(tagID, note.TagIds) { result = append(result, note) } } diff --git a/internal/model/reminder_data.go b/internal/model/reminder_data.go index d1e2c04..0a84474 100644 --- a/internal/model/reminder_data.go +++ b/internal/model/reminder_data.go @@ -34,6 +34,11 @@ type ReminderData struct { BaseStruct } +// Tagger is interface representing ReminderData with TagsFromIds method. +type Tagger interface { + TagsFromIds(tagIDs []int) []string +} + // SyncCalendar syncs pending notes to Cloud Calendar. func (rd *ReminderData) SyncCalendar(calOptions *calendar.Options) error { lookAheadYears := 5 @@ -88,6 +93,7 @@ func (rd *ReminderData) SyncCalendar(calOptions *calendar.Options) error { } // Add events to Cloud Calendar + logger.Info("Fetching events to be Synced.") newEvents, err := rd.GoogleCalendarEvents(timeZone, rd) if err != nil { return err @@ -107,6 +113,8 @@ func (rd *ReminderData) SyncCalendar(calOptions *calendar.Options) error { // GoogleCalendarEvents returns list of Google Calendar Events. func (rd *ReminderData) GoogleCalendarEvents(timezoneIANA string, reminderData *ReminderData) ([]*gc.Event, error) { + logger.Info("Start: GoogleCalendarEvents") + defer logger.Info("End: GoogleCalendarEvents") // get all pending notes allNotes := rd.Notes relevantNotes := allNotes.WithStatus(NoteStatus_Pending).WithCompleteBy() @@ -216,9 +224,9 @@ func (rd *ReminderData) TagFromSlug(slug string) *Tag { return rd.Tags.FromSlug(slug) } -// TagsFromIds returns tags from tagIDs. -func (rd *ReminderData) TagsFromIds(tagIDs []int) Tags { - return rd.Tags.FromIds(tagIDs) +// TagsFromIds returns tag slugs from tagIDs. +func (rd *ReminderData) TagsFromIds(tagIDs []int) []string { + return rd.Tags.FromIds(tagIDs).Slugs() } // TagIdsForGroup gets tag ids for given group. @@ -422,7 +430,7 @@ func (rd *ReminderData) NotesApprachingDueDate(view string) Notes { repeatTagIDs := rd.TagIdsForGroup("repeat") // populating currentNotes for _, note := range pendingNotes { - noteIDsWithRepeat := utils.GetCommonMembersIntSlices(note.TagIds, repeatTagIDs) + noteIDsWithRepeat := utils.GetCommonMembersOfSlices(note.TagIds, repeatTagIDs) // first process notes WITHOUT tag with group "repeat" // start showing such notes 7 days in advance from their due date, and until they are marked done minDay := note.CompleteBy - 7*24*60*60 @@ -444,7 +452,7 @@ func (rd *ReminderData) NotesApprachingDueDate(view string) Notes { // we ignore it repeatAnnuallyTag := rd.TagFromSlug("repeat-annually") repeatMonthlyTag := rd.TagFromSlug("repeat-monthly") - if (repeatAnnuallyTag != nil) && utils.IntPresentInSlice(repeatAnnuallyTag.Id, note.TagIds) { + if (repeatAnnuallyTag != nil) && utils.IsMemberOfSlice(repeatAnnuallyTag.Id, note.TagIds) { _, noteMonth, noteDay := utils.UnixTimestampToTime(note.CompleteBy).Date() noteTimestampCurrent := utils.UnixTimestampForCorrespondingCurrentYear(int(noteMonth), noteDay) noteTimestampPrevious := noteTimestampCurrent - 365*24*60*60 @@ -454,7 +462,7 @@ func (rd *ReminderData) NotesApprachingDueDate(view string) Notes { if view == "long" { daysBefore = int64(365) } - shouldDisplay, matchingTimestamp := utils.IsTimeForRepeatNote(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter) + shouldDisplay, matchingTimestamp := utils.MatchedTimestamp(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter) // temporarity update note's timestamp note.CompleteBy = matchingTimestamp if shouldDisplay { @@ -462,7 +470,7 @@ func (rd *ReminderData) NotesApprachingDueDate(view string) Notes { } } // check for repeat-monthly tag - if (repeatMonthlyTag != nil) && utils.IntPresentInSlice(repeatMonthlyTag.Id, note.TagIds) { + if (repeatMonthlyTag != nil) && utils.IsMemberOfSlice(repeatMonthlyTag.Id, note.TagIds) { _, _, noteDay := utils.UnixTimestampToTime(note.CompleteBy).Date() noteTimestampCurrent := utils.UnixTimestampForCorrespondingCurrentYearMonth(noteDay) noteTimestampPrevious := noteTimestampCurrent - 30*24*60*60 @@ -472,7 +480,7 @@ func (rd *ReminderData) NotesApprachingDueDate(view string) Notes { if view == "long" { daysBefore = int64(31) } - shouldDisplay, matchingTimestamp := utils.IsTimeForRepeatNote(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter) + shouldDisplay, matchingTimestamp := utils.MatchedTimestamp(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter) // temporarity update note's timestamp note.CompleteBy = matchingTimestamp if shouldDisplay { @@ -664,7 +672,7 @@ func (rd *ReminderData) AskTagIds(tagIDs []int) []int { err = nil } // update tagIDs - if (err == nil) && (!utils.IntPresentInSlice(tagID, tagIDs)) { + if (err == nil) && (!utils.IsMemberOfSlice(tagID, tagIDs)) { tagIDs = append(tagIDs, tagID) } // check with user if another tag is to be added @@ -839,7 +847,7 @@ func (rd *ReminderData) PrintNotesAndAskOptions(notes Notes, display_mode string // ask user to select a note promptText := "" if tagID >= 0 { - promptText = fmt.Sprintf("Select Note (for the tag %v %v): ", utils.Symbols["tag"], rd.TagsFromIds([]int{tagID})[0].Slug) + promptText = fmt.Sprintf("Select Note (for the tag %v %v): ", utils.Symbols["tag"], rd.TagsFromIds([]int{tagID})[0]) } else { promptText = "Select Note: " } diff --git a/pkg/calendar/calendar.go b/pkg/calendar/calendar.go index cfb3c53..c3ebcab 100644 --- a/pkg/calendar/calendar.go +++ b/pkg/calendar/calendar.go @@ -22,54 +22,11 @@ import ( const TitlePrefix string = "[reminder] " -// DeleteEvents delete passed cloud calendar events -func DeleteEvents(srv *gc.Service, events []*gc.Event, dryMode bool) error { - if len(events) == 0 { - logger.Warn("No registered events found.") - } else { - deletionCount := 0 - for _, item := range events { - fmt.Printf(" - %q -- %q\n", EventString(item), item.Id) - if dryMode { - logger.Warn(fmt.Sprintf("Dry mode is enabled; skipping deletion of the event %q.", item.Id)) - // continue with next iteration - continue - } - if err := srv.Events.Delete("primary", item.Id).Do(); err != nil { - return fmt.Errorf("Couldn't delete the Calendar event %q | %q | %w", item.Id, item.Summary, err) - } else { - deletionCount += 1 - fmt.Printf(" - Deleted the Calendar event %q | %q\n", item.Id, EventString(item)) - } - } - if deletionCount > 0 { - logger.Warn(fmt.Sprintf("\nWaring! Deletion count is %v; you might like to clear your trash manually by visiting https://calendar.google.com/calendar/u/0/r/trash\n", deletionCount)) - } - } - return nil -} - -// AddEvents adds passed calendar events to the Cloud -func AddEvents(srv *gc.Service, events []*gc.Event, dryMode bool) error { - for _, event := range events { - if dryMode { - logger.Warn(fmt.Sprintf("Dry mode is enabled; skipping insertion of event %q.\n", EventString(event))) - // continue with next iteration - continue - } - _, err := srv.Events.Insert("primary", event).Do() - if err != nil { - // just ignore, but log the error - utils.LogError(err) - } - logger.Info(fmt.Sprintf("Synced the event %q.\n", EventString(event))) - } - return nil -} - // FetchUpcomingEvents returns slice of `Event` objects for // specified number of years, with some default settings. func FetchUpcomingEventsAndDetails(srv *gc.Service, backYears int, aheadYears int, query string) ([]*gc.Event, string, string, error) { + logger.Info("Start: FetchUpcomingEventsAndDetails") + defer logger.Info("End: FetchUpcomingEventsAndDetails") // Get list of all upcomming events, with recurring events as a // unit (and not as separate single events). var allEvents []*gc.Event @@ -122,6 +79,55 @@ func FetchUpcomingEventsAndDetails(srv *gc.Service, backYears int, aheadYears in return allEvents, calendarDetails, timeZone, nil } +// AddEvents adds passed calendar events to the Cloud +func AddEvents(srv *gc.Service, events []*gc.Event, dryMode bool) error { + logger.Info("Start: AddEvents") + defer logger.Info("End: AddEvents") + for _, event := range events { + if dryMode { + logger.Warn(fmt.Sprintf("Dry mode is enabled; skipping insertion of event %q.\n", EventString(event))) + // continue with next iteration + continue + } + _, err := srv.Events.Insert("primary", event).Do() + if err != nil { + // just ignore, but log the error + utils.LogError(err) + } + logger.Info(fmt.Sprintf("Synced the event %q.\n", EventString(event))) + } + return nil +} + +// DeleteEvents delete passed cloud calendar events +func DeleteEvents(srv *gc.Service, events []*gc.Event, dryMode bool) error { + logger.Info("Start: DeleteEvents") + defer logger.Info("End: DeleteEvents") + if len(events) == 0 { + logger.Warn("No registered events found.") + } else { + deletionCount := 0 + for _, item := range events { + fmt.Printf(" - %q -- %q\n", EventString(item), item.Id) + if dryMode { + logger.Warn(fmt.Sprintf("Dry mode is enabled; skipping deletion of the event %q.", item.Id)) + // continue with next iteration + continue + } + if err := srv.Events.Delete("primary", item.Id).Do(); err != nil { + return fmt.Errorf("Couldn't delete the Calendar event %q | %q | %w", item.Id, item.Summary, err) + } else { + deletionCount += 1 + fmt.Printf(" - Deleted the Calendar event %q | %q\n", item.Id, EventString(item)) + } + } + if deletionCount > 0 { + logger.Warn(fmt.Sprintf("\nWaring! Deletion count is %v; you might like to clear your trash manually by visiting https://calendar.google.com/calendar/u/0/r/trash\n", deletionCount)) + } + } + return nil +} + // eventsDetails returns overall event details. func eventsDetails(events *gc.Events) (string, error) { localTime := func(events gc.Events) string { @@ -147,13 +153,19 @@ Calendar details: func EventString(event *gc.Event) string { details := []string{} details = append(details, event.Summary) - details = append(details, event.Start.DateTime) + // Note: if an event is deleted, but still present in trash + // it will have event.Start as nil. + if event.Start != nil { + details = append(details, event.Start.DateTime) + } details = append(details, event.Recurrence...) return strings.Join(details, " | ") } // Get Calendar Service. func GetCalendarService(options *Options) (*gc.Service, error) { + logger.Info("Start: GetCalendarService") + defer logger.Info("End: GetCalendarService") credFile := options.CredentialFile b, err := os.ReadFile(utils.TryConvertTildaBasedPath(credFile)) if err != nil { diff --git a/pkg/utils/functions.go b/pkg/utils/functions.go index cb8b622..7dd17ea 100644 --- a/pkg/utils/functions.go +++ b/pkg/utils/functions.go @@ -5,165 +5,48 @@ package utils import ( "bytes" - "errors" "fmt" "html/template" + "io" "os" "os/exec" "os/user" "path/filepath" "reflect" - "regexp" "strconv" "strings" "testing" "time" "github.com/goyalmunish/reminder/pkg/logger" - - "github.com/AlecAivazis/survey/v2" ) -// Location variable provides location info for `time`. -// It can be set to update behavior of UnixTimestampToTime. -var Location *time.Location - -// CurrentTime function gets current time. -func CurrentTime() time.Time { - return time.Now() -} - -// CurrentUnixTimestamp function gets current unix timestamp. -func CurrentUnixTimestamp() int64 { - return int64(CurrentTime().Unix()) -} - -// UTCLocation function returns UTC location. -func UTCLocation() *time.Location { - location, _ := time.LoadLocation("UTC") - return location -} - -// UnixTimestampToTime function converts unix timestamp to time. -// It serves as central place to switch between UTC and local time. -// by default use local time, but behavior can be changed via `Location`. -// In either case, the value of the time (in seconds) remains same, the -// use of the Location just changes how time is displayed. -// For example, 5:30 PM in SGT is equivalent to 12 Noon in India. -func UnixTimestampToTime(unixTimestamp int64) time.Time { - t := time.Unix(unixTimestamp, 0) - if Location == nil { - return t - } - return t.In(Location) -} - -// UnixTimestampToTimeStr function converts unix timestamp to time string. -func UnixTimestampToTimeStr(unixTimestamp int64, timeFormat string) string { - if unixTimestamp > 0 { - return UnixTimestampToTime(unixTimestamp).Format(timeFormat) - } - return "nil" -} - -// UnixTimestampToLongTimeStr function converts unix timestamp to long time string. -func UnixTimestampToLongTimeStr(unixTimestamp int64) string { - return UnixTimestampToTimeStr(unixTimestamp, time.RFC850) -} - -// UnixTimestampToMediumTimeStr function converts unix timestamp to medium time string. -func UnixTimestampToMediumTimeStr(unixTimestamp int64) string { - return UnixTimestampToTimeStr(unixTimestamp, "02-Jan-06 15:04:05") -} - -// UnixTimestampToShortTimeStr function converts unix timestamp to short time string. -func UnixTimestampToShortTimeStr(unixTimestamp int64) string { - return UnixTimestampToTimeStr(unixTimestamp, "02-Jan-06") -} - -// UnixTimestampForCorrespondingCurrentYear function gets unix timestamp for date corresponding to current year. -func UnixTimestampForCorrespondingCurrentYear(month int, day int) int64 { - currentYear, _, _ := CurrentTime().Date() - format := "2006-1-2" - timeValue, _ := time.Parse(format, fmt.Sprintf("%v-%v-%v", currentYear, month, day)) - return int64(timeValue.Unix()) -} - -// UnixTimestampForCorrespondingCurrentYearMonth function gets unix timestamp for date corresponding to current year and current month. -func UnixTimestampForCorrespondingCurrentYearMonth(day int) int64 { - currentYear, currentMonth, _ := CurrentTime().Date() - format := "2006-1-2" - timeValue, _ := time.Parse(format, fmt.Sprintf("%v-%v-%v", currentYear, int(currentMonth), day)) - return int64(timeValue.Unix()) -} - -// YearForDueDateDDMM return the current year if DD-MM is falling after current date, otherwise returns next year -func YearForDueDateDDMM(dateMonth string) (int, error) { - format := "2-1-2006" - currentTime := CurrentTime() - // set current year as year if year part is missing - timeSplit := strings.Split(dateMonth, "-") - if len(timeSplit) != 2 { - return 0, fmt.Errorf("Provided dateMonth string, %s, is not in DD-MM format", dateMonth) - } - // test with current year - year := currentTime.Year() - dateString := fmt.Sprintf("%s-%d", dateMonth, year) - testTimeValue, err := time.Parse(format, dateString) - if err != nil { - return 0, err - } - if testTimeValue.Unix() <= currentTime.Unix() { - // the due date falls before current date in current year - // so, select next year instead - year += 1 - } - return year, nil -} - -// StrToTime converts RFC3339 time sting to time.Time, and sets location to -// given timezone. If location is blank, then it returns the time as it is. -func StrToTime(tString string, timezone string) (time.Time, error) { - t, err := time.Parse(time.RFC3339, tString) - if err != nil { - return t, fmt.Errorf("Unable to parse the time %v: %w", tString, err) - } - if timezone == "" { - return t, nil - } - location, err := time.LoadLocation(timezone) - if err != nil { - return t, fmt.Errorf("Unable to parse the timezone %v: %w", timezone, err) - } - return t.In(location), nil -} - -// TimeToStr converts time.Time to RFC3339 time string. -func TimeToStr(t time.Time) string { - return t.Format(time.RFC3339) -} - -// GetLocalZone returns the local timezone abbreviation and offset (in time.Duration). -func GetLocalZone() (string, time.Duration) { - abbr, seconds := time.Now().Local().Zone() - dur := time.Duration(seconds * int(time.Second)) - return abbr, dur +// HomeDir return the home directory path for current user. +// Note: It is deliberately defined as a variable to make it +// easer to patch in tests. +var HomeDir func() string = func() string { + usr, _ := user.Current() + dir := usr.HomeDir + return dir } -// GetZoneFromLocation returns zone offset (in time.Duration) for given location string like "Melbourne/Australia". -func GetZoneFromLocation(loc string) (time.Duration, error) { - location, err := time.LoadLocation(loc) +// TerminalSize function gets terminal size. +// Note: It is deliberately defined as a variable to make it +// easer to patch in tests. +var TerminalSize = func() (int, int, error) { + out, err := PerformShellOperation("stty", "size") if err != nil { - return time.Duration(0 * time.Second), err + return 0, 0, err } - _, seconds := time.Now().In(location).Zone() - dur := time.Duration(seconds * int(time.Second)) - - return dur, nil + output := strings.TrimSpace(string(out)) + dims := strings.Split(output, " ") + height, _ := strconv.Atoi(dims[0]) + width, _ := strconv.Atoi(dims[1]) + return height, width, nil } -// IntPresentInSlice function performs membership test for integer based array. -func IntPresentInSlice(a int, list []int) bool { +// IsMemberOf function performs membership test. +func IsMemberOfSlice[V comparable](a V, list []V) bool { for _, b := range list { if b == a { return true @@ -172,29 +55,17 @@ func IntPresentInSlice(a int, list []int) bool { return false } -// GetCommonMembersIntSlices function gets common elements of two integer based slices. -func GetCommonMembersIntSlices(arr1 []int, arr2 []int) []int { - var arr []int - for _, e1 := range arr1 { - for _, e2 := range arr2 { +// GetCommonMembersOfSlices function gets common elements of slices. +func GetCommonMembersOfSlices[V comparable](list1 []V, list2 []V) []V { + var result []V + for _, e1 := range list1 { + for _, e2 := range list2 { if e1 == e2 { - arr = append(arr, e1) + result = append(result, e1) } } } - return arr -} - -// LogError function ignores but prints the error (if present). -func LogError(err error) { - if err != nil { - logger.Error(fmt.Sprintf("%v %v\n", Symbols["error"], err)) - } -} - -// TrimString function returns a trimmed string (with spaces removed from ends). -func TrimString(str string) string { - return strings.TrimSpace(str) + return result } // ChopStrings function returns a chopped strings (to a desired length). @@ -215,27 +86,6 @@ func ChopStrings(texts []string, length int) []string { return choppedStrings } -// ValidateDateString function validates date string (DD-MM-YYYY) or (DD-MM). -// nil is also valid input -func ValidateDateString() survey.Validator { - // return a validator that checks the length of the string - return func(val interface{}) error { - if str, ok := val.(string); ok { - // if the string is shorter than the given value - input := TrimString(str) - re := regexp.MustCompile(`^((0?[1-9]|[12][0-9]|3[01])-(0?[1-9]|1[012])(-((19|20)\d\d))?|(nil))$`) - if re.MatchString(input) { - return nil - } else { - return fmt.Errorf("The input must be in the format DD-MM-YYYY or DD-MM.") - } - } else { - // otherwise we cannot convert the value into a string and cannot enforce length - return fmt.Errorf("Invalid type %v", reflect.TypeOf(val).Name()) - } - } -} - // TemplateResult function runs given go template with given data and function map, and return the result as string. // It is interesting to note that even though data is recieved as `interface{}`, the template // is able to access those attributes without even having to perform type assertion to get @@ -259,13 +109,10 @@ func TemplateResult(reportTemplate string, funcMap template.FuncMap, data interf } } -// Spinner function displays spinner. -func Spinner(delay time.Duration) { - for { - for _, c := range `–\|/` { - fmt.Printf("\r%c", c) - time.Sleep(delay) - } +// LogError function ignores but prints the error (if present). +func LogError(err error) { + if err != nil { + logger.Error(fmt.Sprintf("%v %v\n", Symbols["error"], err)) } } @@ -291,58 +138,36 @@ func AssertEqual(t *testing.T, got interface{}, want interface{}) { } } -// IsTimeForRepeatNote function determines if it is time to show a repeat-based note/task. -// dependency: `CurrentUnixTimestamp` -// It accepts current, previous and next timestamp of a task, and -// checks to see if any of the current timestamp falls in between [TIMESTAMP - DaysBefore, TIMESTAMP + DaysAfter] -func IsTimeForRepeatNote(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter int64) (bool, int64) { - // fmt.Printf("Timestamp Curr: %v %v\n", noteTimestampCurrent, UnixTimestampToTime(noteTimestampCurrent)) - // fmt.Printf("Timestamp Prev: %v %v\n", noteTimestampPrevious, UnixTimestampToTime(noteTimestampPrevious)) - // fmt.Printf("Timestamp Next: %v %v\n", noteTimestampNext, UnixTimestampToTime(noteTimestampNext)) - // fmt.Printf("Days before: %v\n", daysBefore) - // fmt.Printf("Days after: %v\n", daysAfter) - currentTimestamp := CurrentUnixTimestamp() - daysSecs := int64(24 * 60 * 60) - condCurr := ((currentTimestamp >= noteTimestampCurrent-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampCurrent+daysAfter*daysSecs)) - condNext := ((currentTimestamp >= noteTimestampNext-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampNext+daysAfter*daysSecs)) - condPrev := ((currentTimestamp >= noteTimestampPrevious-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampPrevious+daysAfter*daysSecs)) - // find which timestamp matched - matchingTimestamp := noteTimestampPrevious - if condCurr { - matchingTimestamp = noteTimestampCurrent - } else if condNext { - matchingTimestamp = noteTimestampNext +// TryConvertTildaBasedPath converts tilda (~) based path to complete path. +// For a non-tilda based path, return as it is. +func TryConvertTildaBasedPath(path string) string { + homeDir := HomeDir() + if path == "~" { + path = homeDir + } else if strings.HasPrefix(path, "~/") { + // Use strings.HasPrefix so we don't match paths like + // "/something/~/something/" + path = filepath.Join(homeDir, path[2:]) } - return condCurr || condNext || condPrev, matchingTimestamp + return path } -// AskOption function asks option to the user. -// It print error, if encountered any (so that they don't have to printed by calling function). -// It return a tuple (chosen index, chosen string, err if any). -func AskOption(options []string, label string) (int, string, error) { - if len(options) == 0 { - err := errors.New("Empty List") - fmt.Printf("%v Prompt failed %v\n", Symbols["warning"], err) - return -1, "", err - } - // note: any item in options should not have \n character - // otherwise such item is observed to not getting appear - // in the rendered list - var selectedIndex int - prompt := &survey.Select{ - Message: label, - Options: options, - PageSize: 25, - VimMode: true, - } - err := survey.AskOne(prompt, &selectedIndex) +// AskBoolean asks a boolean question to the user. +func AskBoolean(msg string) (bool, error) { + return askBoolean(msg, os.Stdin) +} + +func askBoolean(msg string, in io.Reader) (bool, error) { + var res string + fmt.Printf("%s (y/n): ", msg) + _, err := fmt.Fscanln(in, &res) if err != nil { - // error can happen if user raises an interrupt (such as Ctrl-c, SIGINT) - fmt.Printf("%v Prompt failed %v\n", Symbols["warning"], err) - return -1, "", err + return false, err } - logger.Info(fmt.Sprintf("You chose %d:%q\n", selectedIndex, options[selectedIndex])) - return selectedIndex, options[selectedIndex], nil + logger.Info(fmt.Sprintf("Received response: %q", res)) + res = strings.Trim(res, " \n\t") + res = strings.ToLower(res) + return res == "y", nil } // PerformShellOperation function performs shell operation and return its output. @@ -358,24 +183,11 @@ func PerformShellOperation(exe string, args ...string) (string, error) { return string(bytes), err } -// TerminalSize function gets terminal size. -func TerminalSize() (int, int, error) { - out, err := PerformShellOperation("stty", "size") - if err != nil { - return 0, 0, err - } - output := strings.TrimSpace(string(out)) - dims := strings.Split(output, " ") - height, _ := strconv.Atoi(dims[0]) - width, _ := strconv.Atoi(dims[1]) - return height, width, nil -} - // TerminalWidth function gets terminal width. func TerminalWidth() (int, error) { _, width, err := TerminalSize() if err != nil { - return 0, nil + return 0, err } return width, nil } @@ -408,120 +220,35 @@ func PerformCwdiff(oldFilePath string, newFilePath string) error { return err } -// GeneratePrompt function generates survey.Input. -func GeneratePrompt(promptName string, defaultText string) (string, error) { - var validator survey.Validator - var answer string - var err error - - switch promptName { - case "user_name": - prompt := &survey.Input{ - Message: "User Name: ", - Default: defaultText, - } - validator = survey.Required - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "user_email": - prompt := &survey.Input{ - Message: "User Email: ", - Default: defaultText, - } - validator = survey.MinLength(0) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "tag_slug": - prompt := &survey.Input{ - Message: "Tag Slug: ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "tag_group": - prompt := &survey.Input{ - Message: "Tag Group: ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "tag_another": - prompt := &survey.Input{ - Message: "Add another tag: yes/no (default: no): ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "note_text": - prompt := &survey.Input{ - Message: "Note Text: ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "note_summary": - prompt := &survey.Multiline{ - Message: "Note Summary: ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "note_comment": - prompt := &survey.Multiline{ - Message: "New Comment: ", - Default: defaultText, - } - validator = survey.MinLength(1) - err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) - case "note_completed_by": - prompt := &survey.Input{ - Message: "Due Date (format: DD-MM-YYYY or DD-MM), or enter nil to clear existing value: ", - Default: defaultText, - } - err = survey.AskOne(prompt, &answer, survey.WithValidator(ValidateDateString())) - } - return answer, err -} - -// GenerateNoteSearchSelect function generates survey.Select and return index of selected option. -func GenerateNoteSearchSelect(items []string, searchFunc func(filter string, value string, index int) bool) (int, error) { - var selectedIndex int - prompt := &survey.Select{ - Message: "Search: ", - Options: items, - PageSize: 25, - Filter: searchFunc, - VimMode: true, +// MatchedTimestamp function determines if currentTime matches with (within allowed daysBefore and daysAfter) given 3 timestamps (current, previous, and next). +// That is, it checks to see if any of the current timestamp falls in between [TIMESTAMP - DaysBefore, TIMESTAMP + DaysAfter] +func MatchedTimestamp(noteTimestampCurrent, noteTimestampPrevious, noteTimestampNext, daysBefore, daysAfter int64) (bool, int64) { + // fmt.Printf("Timestamp Curr: %v %v\n", noteTimestampCurrent, UnixTimestampToTime(noteTimestampCurrent)) + // fmt.Printf("Timestamp Prev: %v %v\n", noteTimestampPrevious, UnixTimestampToTime(noteTimestampPrevious)) + // fmt.Printf("Timestamp Next: %v %v\n", noteTimestampNext, UnixTimestampToTime(noteTimestampNext)) + // fmt.Printf("Days before: %v\n", daysBefore) + // fmt.Printf("Days after: %v\n", daysAfter) + currentTimestamp := CurrentUnixTimestamp() + daysSecs := int64(24 * 60 * 60) + condCurr := ((currentTimestamp >= noteTimestampCurrent-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampCurrent+daysAfter*daysSecs)) + condNext := ((currentTimestamp >= noteTimestampNext-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampNext+daysAfter*daysSecs)) + condPrev := ((currentTimestamp >= noteTimestampPrevious-daysBefore*daysSecs) && (currentTimestamp <= noteTimestampPrevious+daysAfter*daysSecs)) + // find which timestamp matched + matchingTimestamp := noteTimestampPrevious + if condCurr { + matchingTimestamp = noteTimestampCurrent + } else if condNext { + matchingTimestamp = noteTimestampNext } - err := survey.AskOne(prompt, &selectedIndex) - return selectedIndex, err -} - -// HomeDir return the home directory path for current user. -func HomeDir() string { - usr, _ := user.Current() - dir := usr.HomeDir - return dir + return condCurr || condNext || condPrev, matchingTimestamp } -// TryConvertTildaBasedPath converts tilda (~) based path to complete path. -// For a non-tilda based path, return as it is. -func TryConvertTildaBasedPath(path string) string { - homeDir := HomeDir() - if path == "~" { - path = homeDir - } else if strings.HasPrefix(path, "~/") { - // Use strings.HasPrefix so we don't match paths like - // "/something/~/something/" - path = filepath.Join(homeDir, path[2:]) +// Spinner function displays spinner. +func Spinner(delay time.Duration) { + for { + for _, c := range `–\|/` { + fmt.Printf("\r%c", c) + time.Sleep(delay) + } } - return path -} - -// AskBoolean asks a boolean question to the user. -func AskBoolean(msg string) bool { - var response string - fmt.Printf("%s (y/n): ", msg) - fmt.Scanln(&response) - response = strings.Trim(response, " \n\t") - response = strings.ToLower(response) - return response == "y" } diff --git a/pkg/utils/functions_test.go b/pkg/utils/functions_test.go index 8ebb35b..f0c8657 100644 --- a/pkg/utils/functions_test.go +++ b/pkg/utils/functions_test.go @@ -17,6 +17,8 @@ import ( "errors" "fmt" "html/template" + "io" + "os" "testing" "time" @@ -25,136 +27,83 @@ import ( utils "github.com/goyalmunish/reminder/pkg/utils" ) -func TestCurrentUnixTimestamp(t *testing.T) { - want := time.Now().Unix() - output := utils.CurrentUnixTimestamp() - utils.AssertEqual(t, output, want) -} - -func TestUnixTimestampToTime(t *testing.T) { - currentTime := utils.CurrentTime() - currentTimestamp := currentTime.Unix() - output := utils.UnixTimestampToTime(currentTimestamp) - utils.AssertEqual(t, output.Format(time.UnixDate), currentTime.Format(time.UnixDate)) -} - -func TestUnixTimestampToTimeStr(t *testing.T) { - utils.Location = utils.UTCLocation() - output := utils.UnixTimestampToTimeStr(int64(1608575176), "02-Jan-06") - utils.AssertEqual(t, output, "21-Dec-20") - output = utils.UnixTimestampToTimeStr(int64(1608575176), time.RFC850) - utils.AssertEqual(t, output, "Monday, 21-Dec-20 18:26:16 UTC") - output = utils.UnixTimestampToTimeStr(int64(-1), "02-Jan-06") - utils.AssertEqual(t, output, "nil") - output = utils.UnixTimestampToTimeStr(int64(1698710400), time.RFC850) - utils.AssertEqual(t, output, "Tuesday, 31-Oct-23 00:00:00 UTC") -} - -func TestUnixTimestampToLongTimeStr(t *testing.T) { - utils.Location = utils.UTCLocation() - output := utils.UnixTimestampToLongTimeStr(int64(1608575176)) - utils.AssertEqual(t, output, "Monday, 21-Dec-20 18:26:16 UTC") -} - -func TestUnixTimestampToMediumTimeStr(t *testing.T) { - utils.Location = utils.UTCLocation() - output := utils.UnixTimestampToMediumTimeStr(int64(1608575176)) - utils.AssertEqual(t, output, "21-Dec-20 18:26:16") -} - -func TestUnixTimestampToShortTimeStr(t *testing.T) { - utils.Location = utils.UTCLocation() - output := utils.UnixTimestampToShortTimeStr(int64(1608575176)) - utils.AssertEqual(t, output, "21-Dec-20") -} - -func TestUnixTimestampForCorrespondingCurrentYear(t *testing.T) { - utils.Location = utils.UTCLocation() - got := utils.UnixTimestampForCorrespondingCurrentYear(9, 30) - utils.UnixTimestampForCorrespondingCurrentYear(6, 30) - utils.AssertEqual(t, got, 7948800) - got = utils.UnixTimestampForCorrespondingCurrentYear(10, 1) - utils.UnixTimestampForCorrespondingCurrentYear(7, 1) - utils.AssertEqual(t, got, 7948800) -} - -func TestUnixTimestampForCorrespondingCurrentYearMonth(t *testing.T) { - utils.Location = utils.UTCLocation() - got := utils.UnixTimestampForCorrespondingCurrentYearMonth(9) - utils.UnixTimestampForCorrespondingCurrentYearMonth(1) - utils.AssertEqual(t, got, 691200) - got = utils.UnixTimestampForCorrespondingCurrentYearMonth(28) - utils.UnixTimestampForCorrespondingCurrentYearMonth(1) - utils.AssertEqual(t, got, 2332800) -} - -func TestStrToTime(t *testing.T) { - // note: refer this format to quickly write table based tests - var tests = []struct { - name string - input string // RFC3339 string - want int64 // Unix64 time value - wantedErr bool // whether an error was expected - }{ - {name: "time in GMT", input: "2022-12-28T00:18:18.929Z", want: 1672186698}, - {name: "time in Melbourne/Australia", input: "2022-12-28T11:18:18.929+11:00", want: 1672186698}, +func TestIsMemberOfSlice(t *testing.T) { + // with int + if got := utils.IsMemberOfSlice(100, []int{-100, 0, 100}); got != true { + t.Error("IsMemberOfSlice failed") } - for position, subtest := range tests { - got, err := utils.StrToTime(subtest.input, "") - if (err != nil) != subtest.wantedErr { - t.Fatalf("StrToTime case %q (position=%d) failed for input %q with error %q", subtest.name, position, subtest.input, err) - } - if got.Unix() != subtest.want { - t.Errorf("StrToTime case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, subtest.input, got.Unix(), subtest.want) - } + if got := utils.IsMemberOfSlice(-100, []int{-100, 0, 100}); got != true { + t.Error("IsMemberOfSlice failed") } -} - -func TestTimeToStr(t *testing.T) { - var tests = []struct { - name string - input string // RFC3339 string - want string // RFC3339 string - }{ - {name: "time in GMT", input: "2022-12-28T00:18:18.929Z", want: "2022-12-28T00:18:18Z"}, - {name: "time in Melbourne/Australia", input: "2022-12-28T11:18:18.929+11:00", want: "2022-12-28T11:18:18+11:00"}, + if got := utils.IsMemberOfSlice(0, []int{-100, 0, 100}); got != true { + t.Error("IsMemberOfSlice failed") } - for position, subtest := range tests { - tm, err := time.Parse(time.RFC3339, subtest.input) - if err != nil { - t.Fatalf("Test input %q is incorrect", subtest.input) - } - got := utils.TimeToStr(tm) - if got != subtest.want { - t.Errorf("TimeToStr case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, tm, got, subtest.want) - } + if got := utils.IsMemberOfSlice(101, []int{-100, 0, 100}); got != false { + t.Error("IsMemberOfSlice failed") + } + if got := utils.IsMemberOfSlice(-99, []int{-100, 0, 100}); got != false { + t.Error("IsMemberOfSlice failed") + } + // with int64 + if got := utils.IsMemberOfSlice(100, []int64{-100, 0, 100}); got != true { + t.Error("IsMemberOfSlice failed") + } + // with float32 + if got := utils.IsMemberOfSlice(100.0, []float32{-100.0, 0.0, 100.0}); got != true { + t.Error("IsMemberOfSlice failed") + } + // with float64 + if got := utils.IsMemberOfSlice(100.0, []int64{-100.0, 0.0, 100.0}); got != true { + t.Error("IsMemberOfSlice failed") + } + // with string + if got := utils.IsMemberOfSlice("100", []string{"-100", "0", "100"}); got != true { + t.Error("IsMemberOfSlice failed") } } -func TestIntPresentInSlice(t *testing.T) { - utils.AssertEqual(t, utils.IntPresentInSlice(100, []int{-100, 0, 100}), true) - utils.AssertEqual(t, utils.IntPresentInSlice(-100, []int{-100, 0, 100}), true) - utils.AssertEqual(t, utils.IntPresentInSlice(101, []int{-100, 0, 100}), false) - utils.AssertEqual(t, utils.IntPresentInSlice(-99, []int{-100, 0, 100}), false) -} - -func TestGetCommonMembersIntSlices(t *testing.T) { +func TestGetCommonMembersOfSlices(t *testing.T) { + // with int utils.AssertEqual(t, - utils.GetCommonMembersIntSlices([]int{-100, 0, 100, 1, 10, 8, 2, -51, 4}, + utils.GetCommonMembersOfSlices([]int{-100, 0, 100, 1, 10, 8, 2, -51, 4}, []int{-21, 100, 0, 8, 4}), []int{0, 100, 8, 4}) utils.AssertEqual(t, - utils.GetCommonMembersIntSlices([]int{-21, 100, 0, 8, 4}, + utils.GetCommonMembersOfSlices([]int{-21, 100, 0, 8, 4}, []int{-100, 0, 100, 1, 10, 8, 2, -51, 4}), []int{100, 0, 8, 4}) utils.AssertEqual(t, - utils.GetCommonMembersIntSlices([]int{2}, + utils.GetCommonMembersOfSlices([]int{2}, []int{-100, 0, 100, 1, 10, 8, 2, -51, 4}), []int{2}) utils.AssertEqual(t, - utils.GetCommonMembersIntSlices([]int{}, + utils.GetCommonMembersOfSlices([]int{}, []int{-100, 0, 100, 1, 10, 8, 2, -51, 4}), []int{}) utils.AssertEqual(t, - utils.GetCommonMembersIntSlices([]int{-100, 0, 100, 1, 10, 8, 2, -51, 4}, + utils.GetCommonMembersOfSlices([]int{-100, 0, 100, 1, 10, 8, 2, -51, 4}, []int{}), []int{}) + // with int64 + utils.AssertEqual(t, + utils.GetCommonMembersOfSlices([]int64{-100, 0, 100, 1, 10, 8, 2, -51, 4}, + []int64{-21, 100, 0, 8, 4}), + []int64{0, 100, 8, 4}) + // with float32 + utils.AssertEqual(t, + utils.GetCommonMembersOfSlices([]float32{-100.0, 0.0, 100.0, 1.0, 10.0, 8.0, 2.0, -51.0, 4.0}, + []float32{-21.0, 100.0, 0.0, 8.0, 4.0}), + []float32{0.0, 100.0, 8.0, 4.0}) + // with float64 + utils.AssertEqual(t, + utils.GetCommonMembersOfSlices([]float64{-100.0, 0.0, 100.0, 1.0, 10.0, 8.0, 2.0, -51.0, 4.0}, + []float64{-21.0, 100.0, 0.0, 8.0, 4.0}), + []float64{0.0, 100.0, 8.0, 4.0}) + // with string + utils.AssertEqual(t, + utils.GetCommonMembersOfSlices([]string{"-100", "0", "100", "1", "10", "8", "2", "-51", "4"}, + []string{"-21", "100", "0", "8", "4"}), + []string{"0", "100", "8", "4"}) } func TestLogError(t *testing.T) { @@ -162,12 +111,6 @@ func TestLogError(t *testing.T) { utils.LogError(err) } -func TestTrimString(t *testing.T) { - utils.AssertEqual(t, utils.TrimString(" str"), "str") - utils.AssertEqual(t, utils.TrimString("str "), "str") - utils.AssertEqual(t, utils.TrimString(" str "), "str") -} - func TestChopStrings(t *testing.T) { strings := []string{"0123456789", "ABCDEFG", "0123"} utils.AssertEqual(t, utils.ChopStrings(strings, 2), strings) @@ -180,18 +123,6 @@ func TestChopStrings(t *testing.T) { utils.AssertEqual(t, utils.ChopStrings(strings, 9), want) } -func TestValidateDateString(t *testing.T) { - errorMsg := "The input must be in the format DD-MM-YYYY or DD-MM." - utils.AssertEqual(t, utils.ValidateDateString()("31-12-2020"), nil) - utils.AssertEqual(t, utils.ValidateDateString()("nil"), nil) - utils.AssertEqual(t, utils.ValidateDateString()("12-31-2020"), errors.New(errorMsg)) - utils.AssertEqual(t, utils.ValidateDateString()("2020-12-31"), errors.New(errorMsg)) - utils.AssertEqual(t, utils.ValidateDateString()("2020-31-"), errors.New(errorMsg)) - utils.AssertEqual(t, utils.ValidateDateString()("2020-31"), errors.New(errorMsg)) - utils.AssertEqual(t, utils.ValidateDateString()("2020-"), errors.New(errorMsg)) - utils.AssertEqual(t, utils.ValidateDateString()("2020"), errors.New(errorMsg)) -} - func TestTemplateResult(t *testing.T) { type TestData struct { DataFile string @@ -216,20 +147,183 @@ Stats of "{{.DataFile}}" return len(validTags) }, } - result, _ := utils.TemplateResult(reportTemplate, funcMap, testData) + // positive case + result, err := utils.TemplateResult(reportTemplate, funcMap, testData) want := ` Stats of "random/file/path" - Number of valid Tags: 2 - Number of Notes: 2 ` + if err != nil { + t.Fatalf("TemplateResult returns error %v", err) + } utils.AssertEqual(t, result, want) + // negative case + _, err = utils.TemplateResult(reportTemplate, funcMap, "") + utils.AssertEqual(t, err == nil, false) + } -func TestTerminalSize(t *testing.T) { - // perhaps stty command doesn't work in tests - // height, width := utils.TerminalSize() - // utils.AssertEqual(t, height > 0, true) - // utils.AssertEqual(t, width > 0, true) +func TestAssertEqual(t *testing.T) { + tests := []struct { + name string + input interface{} + want interface{} + wantedError bool + }{ + { + name: "equal string", + input: "a string", + want: "a string", + wantedError: false, + }, + { + name: "equal integer", + input: 64, + want: 64, + wantedError: true, + }, + { + name: "equal slices", + input: []int{1, 2, 3}, + want: []int{1, 2, 3}, + wantedError: true, + }, + } + for _, subtest := range tests { + t.Run(subtest.name, func(t *testing.T) { + // defer func() { + // if subtest.wantedError { + // p := recover() + // if p != nil { + // err := fmt.Errorf("assertion error: %v", p) + // t.Log(err) + // } else { + // t.Errorf("AssertEqual failed at position %d", position) + // } + // } + // }() + utils.AssertEqual(t, subtest.input, subtest.want) + }) + } +} + +func TestMatchedTimestamp(t *testing.T) { + var tests = []struct { + name string + timestampCurrent int64 + timestampPrevious int64 + timestampNext int64 + daysBefore int64 + daysAfter int64 + currentTime string + wantFound bool + wantTimestamp int64 + }{ + { + name: "matching current via daysBefore", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-11-15T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1668589200, + }, + { + name: "matching current via dayAfter", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-11-19T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1668589200, + }, + { + name: "matching previous via daysBefore", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-10-15T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1665910800, + }, + { + name: "matching previous via dayAfter", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-10-19T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1665910800, + }, + { + name: "matching next via daysBefore", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-12-15T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1671181200, + }, + { + name: "matching next via dayAfter", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-12-19T00:09:00.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1671181200, + }, + { + name: "matching current via daysBefore (at edge)", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-11-14T09:00:01.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1668589200, + }, + { + name: "matching current via dayAfter (at edge)", + timestampCurrent: 1668589200, // 16 November 2022 09:00:00 GMT + timestampPrevious: 1665910800, // 16 October 2022 09:00:00 GMT + timestampNext: 1671181200, // 16 December 2022 09:00:00 GMT + currentTime: "2022-11-21T08:59:59.000Z", + daysBefore: 2, + daysAfter: 5, + wantFound: true, + wantTimestamp: 1668589200, + }, + } + for position, subtest := range tests { + t.Run(subtest.name, func(t *testing.T) { + tm, err := time.Parse(time.RFC3339, subtest.currentTime) + if err != nil { + t.Fatalf("Test input %q is incorrect", subtest.currentTime) + } + utils.CurrentTime = func() time.Time { + return tm + } + gotFound, gotTimestamp := utils.MatchedTimestamp(subtest.timestampCurrent, subtest.timestampNext, subtest.timestampPrevious, subtest.daysBefore, subtest.daysAfter) + if (gotFound != subtest.wantFound) || (gotTimestamp != subtest.wantTimestamp) { + t.Errorf("MatchedTimestamp case %q (position=%d) with input (<%+v>, <%+v>, <%+v>, <%+v>, <%+v>) returns (<%+v>, <%+v>); want (<%+v>, <%+v>)", subtest.name, position, subtest.timestampCurrent, subtest.timestampNext, subtest.timestampPrevious, subtest.daysBefore, subtest.daysAfter, gotFound, gotTimestamp, subtest.wantFound, subtest.wantTimestamp) + } + }) + } } func TestPerformShellOperation(t *testing.T) { @@ -254,9 +348,226 @@ func TestPerformShellOperation(t *testing.T) { utils.AssertEqual(t, err, errors.New("exec: no command")) } +func TestTerminalSize(t *testing.T) { + t.Skip("perhaps stty command doesn't work in tests") + height, width, err := utils.TerminalSize() + if err != nil { + t.Fatalf("TerminalSize didn't work in test: %v", err) + } + utils.AssertEqual(t, height > 0, true) + utils.AssertEqual(t, width > 0, true) +} + +func TestTerminalWidth(t *testing.T) { + var height, width int + var err error + utils.TerminalSize = func() (int, int, error) { + return height, width, err + } + // case 1 + height = 5 + width = 10 + err = nil + got, errR := utils.TerminalWidth() + utils.AssertEqual(t, got, 10) + utils.AssertEqual(t, errR, nil) + // case 1 + height = 0 + width = 0 + err = errors.New("an error") + got, errR = utils.TerminalWidth() + utils.AssertEqual(t, got, 0) + utils.AssertEqual(t, errR, err) +} + +func TestPerformFilePresence(t *testing.T) { + // case 1 + filePath := "./functions.go" + err := utils.PerformFilePresence(filePath) + utils.AssertEqual(t, err == nil, true) + // case 2 + filePath = "./doesnotexist.file" + err = utils.PerformFilePresence(filePath) + utils.AssertEqual(t, err == nil, false) +} + +func TestPerformWhich(t *testing.T) { + // case 1 + err := utils.PerformWhich("go") + utils.AssertEqual(t, err == nil, true) + // case 2 + err = utils.PerformWhich("unknown_command") + utils.AssertEqual(t, err == nil, false) +} +func TestPerformCwdiff(t *testing.T) { + // create temporary files + file1, err := os.CreateTemp("./", "temp_file") + defer os.Remove(file1.Name()) + if err != nil { + t.Fatal("failed to create a file") + } + _, err = io.WriteString(file1, "1\n2\n3") + if err != nil { + t.Fatal("failed to write to the file") + } + file1.Close() + file2, err := os.CreateTemp("./", "temp_file") + defer os.Remove(file2.Name()) + if err != nil { + t.Fatal("failed to create a file") + } + _, err = io.WriteString(file2, "1\n2\n3") + if err != nil { + t.Fatal("failed to write to the file") + } + file3, err := os.CreateTemp("./", "temp_file") + defer os.Remove(file3.Name()) + if err != nil { + t.Fatal("failed to create a file") + } + _, err = io.WriteString(file3, "1\n4\n3") + if err != nil { + t.Fatal("failed to write to the file") + } + file3.Close() + // case 1: same content + err = utils.PerformCwdiff(file1.Name(), file2.Name()) + fmt.Println(err) + utils.AssertEqual(t, err == nil, true) + // case 2: different content + err = utils.PerformCwdiff(file1.Name(), file3.Name()) + fmt.Println(err) + utils.AssertEqual(t, err == nil, false) +} + +func TestPerformCat(t *testing.T) { + // case 1 + filePath := "./functions.go" + err := utils.PerformCat(filePath) + utils.AssertEqual(t, err == nil, true) + // case 2 + filePath = "./doesnotexist.file" + err = utils.PerformFilePresence(filePath) + utils.AssertEqual(t, err == nil, false) +} + func TestHomeDir(t *testing.T) { got := utils.HomeDir() if len(got) == 0 { t.Errorf("HomeDir function returns blank path") } } + +func TestTryConvertTildaBasedPath(t *testing.T) { + utils.HomeDir = func() string { + return "/Users/goyalmunish/" + } + var tests = []struct { + name string + input string + want string + }{ + { + name: "path within home directory", + input: "~/afile.txt", + want: "/Users/goyalmunish/afile.txt", + }, + { + name: "path somewhere in nested diretory", + input: "~/dir1/dir2/afile.txt", + want: "/Users/goyalmunish/dir1/dir2/afile.txt", + }, + { + name: "path starting with '/'", + input: "/dir1/dir2/afile.txt", + want: "/dir1/dir2/afile.txt", + }, + { + name: "path starting with '~'", + input: "~dir1/afile.txt", + want: "~dir1/afile.txt", + }, + { + name: "empty path", + input: "", + want: "", + }, + } + for position, subtest := range tests { + t.Run(subtest.name, func(t *testing.T) { + got := utils.TryConvertTildaBasedPath(subtest.input) + if got != subtest.want { // or reflect.DeepEqual, https://pkg.go.dev/reflect#DeepEqual + t.Errorf("TryConvertTildaBasedPath case %q (position=%d) with input <%+v> returns <%+v>; want <%+v>", subtest.name, position, subtest.input, got, subtest.want) + } + }) + } +} + +func TestAskBoolean(t *testing.T) { + var tests = []struct { + name string + input string + buffer string + want bool + wantedErr bool + }{ + { + name: "y", + input: "y", + want: true, + wantedErr: false, + }, + { + name: "y with padding with spaces", + input: " y ", + want: true, + wantedErr: false, + }, + { + name: "word containing y", + input: "ayb", + want: false, + wantedErr: false, + }, + { + name: "n", + input: "n", + want: false, + wantedErr: false, + }, + { + name: "word containing n", + input: "anb", + want: false, + wantedErr: false, + }, + } + for position, subtest := range tests { + t.Run(subtest.name, func(t *testing.T) { + in, err := os.CreateTemp("./", "a_temp_file") + if err != nil { + t.Fatal(err) + } + defer os.Remove(in.Name()) + defer in.Close() + _, err = io.WriteString(in, subtest.input) + if err != nil { + t.Fatal(err) + } + _, err = in.Seek(0, io.SeekStart) + if err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + got, err := utils.PrivateAskBoolean("greeting message", in) + if (err != nil) != subtest.wantedErr { + t.Fatalf("AskBoolean case %q (position=%d) with input <%+v> returns error <%v>", subtest.name, position, subtest.input, err) + } + if got != subtest.want { + t.Errorf("AskBoolean case %q (position=%d) with input <%+v> returns <%+v>; want <%+v>", subtest.name, position, subtest.input, got, subtest.want) + } + }) + } +} diff --git a/pkg/utils/survey.go b/pkg/utils/survey.go new file mode 100644 index 0000000..e0e512a --- /dev/null +++ b/pkg/utils/survey.go @@ -0,0 +1,125 @@ +package utils + +import ( + "errors" + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/goyalmunish/reminder/pkg/logger" +) + +// AskOption function asks option to the user. +// It print error, if encountered any (so that they don't have to printed by calling function). +// It return a tuple (chosen index, chosen string, err if any). +func AskOption(options []string, label string) (int, string, error) { + if len(options) == 0 { + err := errors.New("Empty List") + fmt.Printf("%v Prompt failed %v\n", Symbols["warning"], err) + return -1, "", err + } + // note: any item in options should not have \n character + // otherwise such item is observed to not getting appear + // in the rendered list + var selectedIndex int + prompt := &survey.Select{ + Message: label, + Options: options, + PageSize: 25, + VimMode: true, + } + err := survey.AskOne(prompt, &selectedIndex) + if err != nil { + // error can happen if user raises an interrupt (such as Ctrl-c, SIGINT) + fmt.Printf("%v Prompt failed %v\n", Symbols["warning"], err) + return -1, "", err + } + logger.Info(fmt.Sprintf("You chose %d:%q\n", selectedIndex, options[selectedIndex])) + return selectedIndex, options[selectedIndex], nil +} + +// GeneratePrompt function generates survey.Input. +func GeneratePrompt(promptName string, defaultText string) (string, error) { + var validator survey.Validator + var answer string + var err error + + switch promptName { + case "user_name": + prompt := &survey.Input{ + Message: "User Name: ", + Default: defaultText, + } + validator = survey.Required + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "user_email": + prompt := &survey.Input{ + Message: "User Email: ", + Default: defaultText, + } + validator = survey.MinLength(0) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "tag_slug": + prompt := &survey.Input{ + Message: "Tag Slug: ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "tag_group": + prompt := &survey.Input{ + Message: "Tag Group: ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "tag_another": + prompt := &survey.Input{ + Message: "Add another tag: yes/no (default: no): ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "note_text": + prompt := &survey.Input{ + Message: "Note Text: ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "note_summary": + prompt := &survey.Multiline{ + Message: "Note Summary: ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "note_comment": + prompt := &survey.Multiline{ + Message: "New Comment: ", + Default: defaultText, + } + validator = survey.MinLength(1) + err = survey.AskOne(prompt, &answer, survey.WithValidator(validator)) + case "note_completed_by": + prompt := &survey.Input{ + Message: "Due Date (format: DD-MM-YYYY or DD-MM), or enter nil to clear existing value: ", + Default: defaultText, + } + err = survey.AskOne(prompt, &answer, survey.WithValidator(ValidateDateString())) + } + return answer, err +} + +// GenerateNoteSearchSelect function generates survey.Select and return index of selected option. +func GenerateNoteSearchSelect(items []string, searchFunc func(filter string, value string, index int) bool) (int, error) { + var selectedIndex int + prompt := &survey.Select{ + Message: "Search: ", + Options: items, + PageSize: 25, + Filter: searchFunc, + VimMode: true, + } + err := survey.AskOne(prompt, &selectedIndex) + return selectedIndex, err +} diff --git a/pkg/utils/survey_test.go b/pkg/utils/survey_test.go new file mode 100644 index 0000000..90c372d --- /dev/null +++ b/pkg/utils/survey_test.go @@ -0,0 +1 @@ +package utils_test diff --git a/pkg/utils/time.go b/pkg/utils/time.go new file mode 100644 index 0000000..8c6a40c --- /dev/null +++ b/pkg/utils/time.go @@ -0,0 +1,173 @@ +package utils + +import ( + "fmt" + "reflect" + "regexp" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" +) + +// Location variable provides location info for `time`. +// It can be set to update behavior of UnixTimestampToTime. +var Location *time.Location + +// CurrentTime function gets current time. +// Note: It is deliberately defined as a variable to make it +// easer to patch in tests. +var CurrentTime = func() time.Time { + return time.Now() +} + +// CurrentUnixTimestamp function gets current unix timestamp. +func CurrentUnixTimestamp() int64 { + return int64(CurrentTime().Unix()) +} + +// UTCLocation function returns UTC location. +func UTCLocation() *time.Location { + location, _ := time.LoadLocation("UTC") + return location +} + +// UnixTimestampToTime function converts unix timestamp to time. +// It serves as central place to switch between UTC and local time. +// by default use local time, but behavior can be changed via `Location`. +// In either case, the value of the time (in seconds) remains same, the +// use of the Location just changes how time is displayed. +// For example, 5:30 PM in SGT is equivalent to 12 Noon in India. +func UnixTimestampToTime(unixTimestamp int64) time.Time { + t := time.Unix(unixTimestamp, 0) + if Location == nil { + return t + } + return t.In(Location) +} + +// UnixTimestampToTimeStr function converts unix timestamp to time string. +func UnixTimestampToTimeStr(unixTimestamp int64, timeFormat string) string { + if unixTimestamp > 0 { + return UnixTimestampToTime(unixTimestamp).Format(timeFormat) + } + return "nil" +} + +// UnixTimestampToLongTimeStr function converts unix timestamp to long time string. +func UnixTimestampToLongTimeStr(unixTimestamp int64) string { + return UnixTimestampToTimeStr(unixTimestamp, time.RFC850) +} + +// UnixTimestampToMediumTimeStr function converts unix timestamp to medium time string. +func UnixTimestampToMediumTimeStr(unixTimestamp int64) string { + return UnixTimestampToTimeStr(unixTimestamp, "02-Jan-06 15:04:05") +} + +// UnixTimestampToShortTimeStr function converts unix timestamp to short time string. +func UnixTimestampToShortTimeStr(unixTimestamp int64) string { + return UnixTimestampToTimeStr(unixTimestamp, "02-Jan-06") +} + +// UnixTimestampForCorrespondingCurrentYear function gets unix timestamp for date corresponding to current year. +func UnixTimestampForCorrespondingCurrentYear(month int, day int) int64 { + currentYear, _, _ := CurrentTime().Date() + format := "2006-1-2" + timeValue, _ := time.Parse(format, fmt.Sprintf("%v-%v-%v", currentYear, month, day)) + return int64(timeValue.Unix()) +} + +// UnixTimestampForCorrespondingCurrentYearMonth function gets unix timestamp for date corresponding to current year and current month. +func UnixTimestampForCorrespondingCurrentYearMonth(day int) int64 { + currentYear, currentMonth, _ := CurrentTime().Date() + format := "2006-1-2" + timeValue, _ := time.Parse(format, fmt.Sprintf("%v-%v-%v", currentYear, int(currentMonth), day)) + return int64(timeValue.Unix()) +} + +// YearForDueDateDDMM return the current year if DD-MM is falling after current date, otherwise returns next year +func YearForDueDateDDMM(dateMonth string) (int, error) { + format := "2-1-2006" + currentTime := CurrentTime() + // set current year as year if year part is missing + timeSplit := strings.Split(dateMonth, "-") + if len(timeSplit) != 2 { + return 0, fmt.Errorf("Provided dateMonth string, %s, is not in DD-MM format", dateMonth) + } + // try with current year + year := currentTime.Year() + dateString := fmt.Sprintf("%s-%d", dateMonth, year) + tryTimeValue, err := time.Parse(format, dateString) + if err != nil { + return 0, err + } + if tryTimeValue.Unix() <= currentTime.Unix() { + // the due date falls before current date in current year + // so, select next year instead + year += 1 + } + return year, nil +} + +// StrToTime converts RFC3339 time sting to time.Time, and sets location to +// given timezone. If location is blank, then it returns the time as it is. +func StrToTime(tString string, timezone string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, tString) + if err != nil { + return t, fmt.Errorf("Unable to parse the time %v: %w", tString, err) + } + if timezone == "" { + return t, nil + } + location, err := time.LoadLocation(timezone) + if err != nil { + return t, fmt.Errorf("Unable to parse the timezone %v: %w", timezone, err) + } + fmt.Println(t.In(location).Format(time.RFC3339)) + return t.In(location), nil +} + +// TimeToStr converts time.Time to RFC3339 time string. +func TimeToStr(t time.Time) string { + return t.Format(time.RFC3339) +} + +// GetLocalZone returns the local timezone abbreviation and offset (in time.Duration). +func GetLocalZone() (string, time.Duration) { + abbr, seconds := CurrentTime().Zone() + dur := time.Duration(seconds * int(time.Second)) + return abbr, dur +} + +// GetZoneFromLocation returns zone offset (in time.Duration) for given location string like "Melbourne/Australia". +func GetZoneFromLocation(loc string) (time.Duration, error) { + location, err := time.LoadLocation(loc) + if err != nil { + return time.Duration(0 * time.Second), err + } + _, seconds := CurrentTime().In(location).Zone() + dur := time.Duration(seconds * int(time.Second)) + + return dur, nil +} + +// ValidateDateString function validates date string (DD-MM-YYYY) or (DD-MM). +// nil is also valid input +func ValidateDateString() survey.Validator { + // return a validator that checks the length of the string + return func(val interface{}) error { + if str, ok := val.(string); ok { + // if the string is shorter than the given value + input := strings.TrimSpace(str) + re := regexp.MustCompile(`^((0?[1-9]|[12][0-9]|3[01])-(0?[1-9]|1[012])(-((19|20)\d\d))?|(nil))$`) + if re.MatchString(input) { + return nil + } else { + return fmt.Errorf("The input must be in the format DD-MM-YYYY or DD-MM.") + } + } else { + // otherwise we cannot convert the value into a string and cannot enforce length + return fmt.Errorf("Invalid type %v", reflect.TypeOf(val).Name()) + } + } +} diff --git a/pkg/utils/time_test.go b/pkg/utils/time_test.go new file mode 100644 index 0000000..b72782b --- /dev/null +++ b/pkg/utils/time_test.go @@ -0,0 +1,306 @@ +package utils_test + +import ( + "errors" + "testing" + "time" + + utils "github.com/goyalmunish/reminder/pkg/utils" +) + +func TestCurrentUnixTimestamp(t *testing.T) { + utils.CurrentTime = func() time.Time { return time.Now() } + want := time.Now().Unix() + output := utils.CurrentUnixTimestamp() + utils.AssertEqual(t, output, want) +} + +func TestUnixTimestampToTime(t *testing.T) { + currentTime := time.Now() + currentTimestamp := currentTime.Unix() + output := utils.UnixTimestampToTime(currentTimestamp) + utils.AssertEqual(t, output.Format(time.UnixDate), currentTime.Format(time.UnixDate)) +} + +func TestUnixTimestampToTimeStr(t *testing.T) { + utils.Location = utils.UTCLocation() + output := utils.UnixTimestampToTimeStr(int64(1608575176), "02-Jan-06") + utils.AssertEqual(t, output, "21-Dec-20") + output = utils.UnixTimestampToTimeStr(int64(1608575176), time.RFC850) + utils.AssertEqual(t, output, "Monday, 21-Dec-20 18:26:16 UTC") + output = utils.UnixTimestampToTimeStr(int64(-1), "02-Jan-06") + utils.AssertEqual(t, output, "nil") + output = utils.UnixTimestampToTimeStr(int64(1698710400), time.RFC850) + utils.AssertEqual(t, output, "Tuesday, 31-Oct-23 00:00:00 UTC") +} + +func TestUnixTimestampToLongTimeStr(t *testing.T) { + utils.Location = utils.UTCLocation() + output := utils.UnixTimestampToLongTimeStr(int64(1608575176)) + utils.AssertEqual(t, output, "Monday, 21-Dec-20 18:26:16 UTC") +} + +func TestUnixTimestampToMediumTimeStr(t *testing.T) { + utils.Location = utils.UTCLocation() + output := utils.UnixTimestampToMediumTimeStr(int64(1608575176)) + utils.AssertEqual(t, output, "21-Dec-20 18:26:16") +} + +func TestUnixTimestampToShortTimeStr(t *testing.T) { + utils.Location = utils.UTCLocation() + output := utils.UnixTimestampToShortTimeStr(int64(1608575176)) + utils.AssertEqual(t, output, "21-Dec-20") +} + +func TestUnixTimestampForCorrespondingCurrentYear(t *testing.T) { + utils.Location = utils.UTCLocation() + got := utils.UnixTimestampForCorrespondingCurrentYear(9, 30) - utils.UnixTimestampForCorrespondingCurrentYear(6, 30) + utils.AssertEqual(t, got, 7948800) + got = utils.UnixTimestampForCorrespondingCurrentYear(10, 1) - utils.UnixTimestampForCorrespondingCurrentYear(7, 1) + utils.AssertEqual(t, got, 7948800) +} + +func TestUnixTimestampForCorrespondingCurrentYearMonth(t *testing.T) { + utils.Location = utils.UTCLocation() + got := utils.UnixTimestampForCorrespondingCurrentYearMonth(9) - utils.UnixTimestampForCorrespondingCurrentYearMonth(1) + utils.AssertEqual(t, got, 691200) + got = utils.UnixTimestampForCorrespondingCurrentYearMonth(28) - utils.UnixTimestampForCorrespondingCurrentYearMonth(1) + utils.AssertEqual(t, got, 2332800) +} + +func TestYearForDueDateDDMM(t *testing.T) { + var tests = []struct { + name string + input string + currentTime string // RFC3339 string + want int + wantedErr bool + }{ + { + name: "DDMM before currentTime", + input: "01-03", + currentTime: "2022-04-02T00:18:18.929Z", + want: 2023, + wantedErr: false, + }, + { + name: "DDMM before currentTime", + input: "25-11", + currentTime: "2022-11-27T11:18:18.929+11:00", + want: 2023, + wantedErr: false, + }, + { + name: "DDMM after currentTime", + input: "05-07", + currentTime: "2022-04-02T00:18:18.929Z", + want: 2022, + wantedErr: false, + }, + { + name: "DDMM after currentTime", + input: "28-12", + currentTime: "2022-11-27T11:18:18.929+11:00", + want: 2022, + wantedErr: false, + }, + { + name: "simple DDMM", + input: "1-3", + currentTime: "2022-04-02T00:18:18.929Z", + want: 2023, + wantedErr: false, + }, + { + name: "another simple DDMM", + input: "3-4", + currentTime: "2022-04-02T00:18:18.929Z", + want: 2022, + wantedErr: false, + }, + { + name: "invalid input", + input: "4", + currentTime: "2022-04-02T00:18:18.929Z", + wantedErr: true, + }, + { + name: "invalid input", + input: "99-99", + currentTime: "2022-04-02T00:18:18.929Z", + wantedErr: true, + }, + } + for position, subtest := range tests { + tm, err := time.Parse(time.RFC3339, subtest.currentTime) + if err != nil { + t.Fatalf("Test input %q is incorrect", subtest.input) + } + utils.CurrentTime = func() time.Time { + return tm + } + got, err := utils.YearForDueDateDDMM(subtest.input) + if (err != nil) != subtest.wantedErr { + t.Fatalf("YearForDueDateDDMM case %q (position=%d) with input <%+v> returns error <%v>", subtest.name, position, subtest.input, err) + } + if got != subtest.want { + t.Errorf("YearForDueDateDDMM case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, tm, got, subtest.want) + } + } +} + +func TestStrToTime(t *testing.T) { + // note: refer this format to quickly write table based tests + var tests = []struct { + name string + input string // RFC3339 string + timezone string + want string + wantedErr bool // whether an error was expected + }{ + {name: "time in GMT", input: "2022-12-28T00:18:18.929Z", want: "2022-12-28T00:18:18Z"}, + {name: "time in Melbourne/Australia", input: "2022-12-28T11:18:18.929+11:00", want: "2022-12-28T11:18:18+11:00"}, + {name: "time in Melbourne/Australia to UTC", input: "2022-12-27T11:18:18.929+11:00", want: "2022-12-27T00:18:18Z", timezone: "UTC"}, + {name: "with invalid timeze", input: "2022-12-27T11:18:18.929+11:00", want: "2022-12-27T11:18:18+11:00", timezone: "INVALID", wantedErr: true}, + {name: "with invalid time string", input: "SomeInvalidValue", want: "0001-01-01T00:00:00Z", timezone: "UTC", wantedErr: true}, + } + for position, subtest := range tests { + got, err := utils.StrToTime(subtest.input, subtest.timezone) + if (err != nil) != subtest.wantedErr { + t.Fatalf("StrToTime case %q (position=%d) failed for input %q with error %q", subtest.name, position, subtest.input, err) + } + gotStr := got.Format(time.RFC3339) + if gotStr != subtest.want { + t.Errorf("StrToTime case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, subtest.input, gotStr, subtest.want) + } + } +} + +func TestTimeToStr(t *testing.T) { + var tests = []struct { + name string + input string // RFC3339 string + want string // RFC3339 string + }{ + {name: "time in GMT", input: "2022-12-28T00:18:18.929Z", want: "2022-12-28T00:18:18Z"}, + {name: "time in Melbourne/Australia", input: "2022-12-28T11:18:18.929+11:00", want: "2022-12-28T11:18:18+11:00"}, + } + for position, subtest := range tests { + tm, err := time.Parse(time.RFC3339, subtest.input) + if err != nil { + t.Fatalf("Test input %q is incorrect", subtest.input) + } + got := utils.TimeToStr(tm) + if got != subtest.want { + t.Errorf("TimeToStr case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, tm, got, subtest.want) + } + } +} +func TestGetLocalZone(t *testing.T) { + var tests = []struct { + name string + currentTime string // RFC3339 string + wantAbbr string + wantDur time.Duration + }{ + { + name: "time in UTC", + currentTime: "2022-04-02T00:18:18.929Z", + wantAbbr: "UTC", + wantDur: 0, + }, + { + name: "time in IST", + currentTime: "2022-11-27T11:18:18.929+05:30", + wantAbbr: "", + wantDur: time.Duration(5*time.Hour + 30*time.Minute), + }, + { + name: "time in AEDT", + currentTime: "2022-11-27T11:18:18.929+11:00", + wantAbbr: "AEDT", + wantDur: time.Duration(11 * time.Hour), + }, + } + for position, subtest := range tests { + tm, err := time.Parse(time.RFC3339, subtest.currentTime) + if err != nil { + t.Fatalf("Test input %q is incorrect", subtest.currentTime) + } + utils.CurrentTime = func() time.Time { + return tm + } + abbr, dur := utils.GetLocalZone() + if (abbr != subtest.wantAbbr) || (dur != subtest.wantDur) { + t.Errorf("GetLocalZone case %q (position=%d) failed for input %q; returns <%+v>, <%+v>; wants <%+v>, <%+v>", subtest.name, position, tm, abbr, dur, subtest.wantAbbr, subtest.wantDur) + } + } +} + +func TestGetZoneFromLocation(t *testing.T) { + var tests = []struct { + name string + input string + want time.Duration + wantedErr bool + }{ + { + name: "GMT", + input: "GMT", + want: time.Duration(0 * time.Hour), + wantedErr: false, + }, + { + name: "UTC", + input: "UTC", + want: time.Duration(0 * time.Hour), + wantedErr: false, + }, + { + name: "Asia/Singapore", + input: "Asia/Singapore", + want: time.Duration(8 * time.Hour), + wantedErr: false, + }, + { + name: "Asia/Calcutta", + input: "Asia/Calcutta", + want: time.Duration(5*time.Hour + 30*time.Minute), + wantedErr: false, + }, + { + name: "Australia/Melbourne", + input: "Australia/Melbourne", + want: time.Duration(11 * time.Hour), + wantedErr: false, + }, + { + name: "Invalid Location", + input: "SomeInvalidValue", + want: 0, + wantedErr: true, + }, + } + for position, subtest := range tests { + got, err := utils.GetZoneFromLocation(subtest.input) + if (err != nil) != subtest.wantedErr { + t.Fatalf("GetZoneFromLocation case %q (position=%d) with input <%+v> returns error <%v>", subtest.name, position, subtest.input, err) + } + if got != subtest.want { + t.Errorf("GetZoneFromLocation case %q (position=%d) failed for input %q; returns <%+v>; wants <%+v>", subtest.name, position, subtest.input, got, subtest.want) + } + } +} + +func TestValidateDateString(t *testing.T) { + errorMsg := "The input must be in the format DD-MM-YYYY or DD-MM." + utils.AssertEqual(t, utils.ValidateDateString()("31-12-2020"), nil) + utils.AssertEqual(t, utils.ValidateDateString()("nil"), nil) + utils.AssertEqual(t, utils.ValidateDateString()("12-31-2020"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()("2020-12-31"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()("2020-31-"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()("2020-31"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()("2020-"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()("2020"), errors.New(errorMsg)) + utils.AssertEqual(t, utils.ValidateDateString()(2020), errors.New("Invalid type int")) +} diff --git a/pkg/utils/utils_linker_test.go b/pkg/utils/utils_linker_test.go new file mode 100644 index 0000000..940e1f2 --- /dev/null +++ b/pkg/utils/utils_linker_test.go @@ -0,0 +1,7 @@ +package utils + +import "io" + +func PrivateAskBoolean(msg string, in io.Reader) (bool, error) { + return askBoolean(msg, in) +} diff --git a/scripts/go_coverage b/scripts/go_coverage new file mode 100644 index 0000000..5bcb9ba --- /dev/null +++ b/scripts/go_coverage @@ -0,0 +1,8 @@ +if [ "$CONSOLE_PRINT" = "true" ]; then + go test -v -coverprofile=c.out ./... +else + go test -coverprofile=c.out ./... +fi + +go tool cover -html=c.out +echo "Check your browser window" diff --git a/scripts/go_test b/scripts/go_test index d5488bf..98b74c5 100644 --- a/scripts/go_test +++ b/scripts/go_test @@ -18,7 +18,7 @@ # run_go_test "cmd/reminder/" if [ "$CONSOLE_PRINT" = "true" ]; then - go test -count=1 -cover -v ./... + go test -v -count=1 -cover ./... else go test -count=1 -cover ./... fi diff --git a/scripts/open_data_file b/scripts/open_data_file new file mode 100644 index 0000000..cbf2475 --- /dev/null +++ b/scripts/open_data_file @@ -0,0 +1,3 @@ +data_file_path=$(cat ./config/default.yaml | grep data_file | awk '{ print $2 }') +echo "Data File: ${data_file_path}" +nvim "${data_file_path/#\~/$HOME}"