diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..425fa53 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +on: + workflow_dispatch: + push: + tags: + - "*" + +permissions: + contents: write + +jobs: + build: + name: GoReleaser Build + runs-on: ubuntu-latest + + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set Up Go + uses: actions/setup-go@v5 + with: + go-version: "1.x" + id: go + + - name: run GoReleaser + uses: goreleaser/goreleaser-action@v6 + env: + HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: release --clean diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..ae121cc --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,87 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: dtmate + binary: dtmate + dir: ./cmd/dtmate + ldflags: + - -extldflags "-static" -s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} + env: + - CGO_ENABLED=0 + goos: + - linux + - freebsd + - darwin + goarch: + - amd64 + - arm64 + - arm + - ppc64le + goarm: + - "7" + ignore: + - goos: freebsd + goarch: arm64 + - goos: freebsd + goarch: arm + - goos: freebsd + goarch: ppc64le + - goos: darwin + goarch: arm + - goos: darwin + goarch: ppc64le + + - id: dtmate-win + binary: dtmate + dir: ./cmd/dtmate + ldflags: + - -extldflags "-static" -s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} + env: + - CGO_ENABLED=0 + goos: + - windows + goarch: + - amd64 + hooks: + post: + - upx -9 "{{ .Path }}" + +archives: + - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format: tar.xz + format_overrides: + - goos: windows + format: zip + wrap_in_directory: true + files: + - LICENSE + - README.md + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}--checksums.txt" +release: + draft: false +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +brews: + - name: dtmate + repository: + owner: jftuga + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TOKEN }}" + commit_author: + name: jftuga + email: jftuga@users.noreply.github.com + homepage: https://github.com/jftuga/DateTimeMate + description: "dtmate: output the difference between date, time or duration" + test: system "#{bin}/dtmate -v" + install: bin.install "dtmate" diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DateTimeMate.iml b/.idea/DateTimeMate.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/DateTimeMate.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..3a7375b --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,40 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6d0aeaf --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/DateTimeMate.go b/DateTimeMate.go new file mode 100644 index 0000000..c658bc1 --- /dev/null +++ b/DateTimeMate.go @@ -0,0 +1,28 @@ +package DateTimeMate + +import ( + "github.com/golang-module/carbon/v2" + "strings" +) + +const ( + ModName string = "DateTimeMate" + ModVersion string = "1.0.0" + ModUrl string = "https://github.com/jftuga/DateTimeMate" +) + +// convertRelativeDateToActual converts "yesterday", "today", "tomorrow" +// into actual dates; yesterday and tomorrow are -/+ 24 hours of current time +func convertRelativeDateToActual(from string) string { + switch strings.ToLower(from) { + case "now": + return carbon.Now().String() + case "today": + return carbon.Now().String() + case "yesterday": + return carbon.Yesterday().String() + case "tomorrow": + return carbon.Tomorrow().String() + } + return from +} diff --git a/DateTimeMate_test.go b/DateTimeMate_test.go new file mode 100644 index 0000000..608a700 --- /dev/null +++ b/DateTimeMate_test.go @@ -0,0 +1,361 @@ +package DateTimeMate + +import ( + "fmt" + "github.com/golang-module/carbon/v2" + "strings" + "testing" +) + +func testStartEnd(t *testing.T, start, end string, brief bool, correct string) { + diff := NewDiff( + DiffWithStart(start), + DiffWithEnd(end), + DiffWithBrief(brief)) + result, _, err := diff.CalculateDiff() + if err != nil { + t.Error(err) + } + if result != correct { + t.Errorf("[computed: %v] != [correct: %v]", result, correct) + } +} + +func testAddSubContains(t *testing.T, from, period, correctAdd, correctSub string) { + dur := NewDur( + DurWithFrom(from), + DurWithDur(period)) + future, err := dur.Add() + if err != nil { + t.Error(err) + } + if !strings.Contains(future[0], correctAdd) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, future, correctAdd) + } + + past, err := dur.Sub() + if err != nil { + t.Error(err) + } + if !strings.Contains(past[0], correctSub) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, past, correctSub) + } +} + +func testAddSubOutputFormat(t *testing.T, from, period, outputFormat, correctAdd, correctSub string) { + dur := NewDur( + DurWithFrom(from), + DurWithDur(period), + DurWithOutputFormat(outputFormat)) + future, err := dur.Add() + if err != nil { + t.Error(err) + } + if !strings.Contains(future[0], correctAdd) { + t.Errorf("\n[from: %v]\n[period: %v]\n[computed: %v] does not contain:\n[correct: %v]", from, period, future[0], correctAdd) + } + return + + past, err := dur.Sub() + if err != nil { + t.Error(err) + } + if !strings.Contains(past[0], correctSub) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, past, correctSub) + } +} + +func testAddSubWithRepeat(t *testing.T, from, period string, correctAdd, correctSub []string, repeat int) { + dur := NewDur( + DurWithFrom(from), + DurWithDur(period), + DurWithRepeat(repeat)) + future, err := dur.Add() + if err != nil { + t.Error(err) + } + for i := range len(future) { + if !strings.Contains(future[i], correctAdd[i]) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, future[i], correctAdd[i]) + } + } + + past, err := dur.Sub() + if err != nil { + t.Error(err) + } + for j := range len(past) { + if !strings.Contains(past[j], correctSub[j]) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, past[j], correctSub[j]) + } + } +} + +func testAddUntil(t *testing.T, from, until, periodFuture string, correctAdd []string) { + durFuture := NewDur( + DurWithFrom(from), + DurWithDur(periodFuture), + DurWithUntil(until)) + future, err := durFuture.Add() + if err != nil { + t.Error(err) + } + + for i := range len(future) { + if !strings.Contains(future[i], correctAdd[i]) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, future[i], correctAdd[i]) + break + } + } +} + +func testSubUntil(t *testing.T, from, until, periodPast string, correctSub []string) { + durPast := NewDur( + DurWithFrom(from), + DurWithDur(periodPast), + DurWithUntil(until)) + past, err := durPast.Sub() + if err != nil { + t.Error(err) + } + for i := range len(past) { + if !strings.Contains(past[i], correctSub[i]) { + t.Errorf("[from: %v] [computed: %v] does not contain: [correct: %v]", from, past[i], correctSub[i]) + } + } +} + +func TestTwoTimesSameDay(t *testing.T) { + start := "12:00:00" + end := "15:30:45" + correct := "3 hours 30 minutes 45 seconds" + correctBrief := "3h30m45s" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestAmPm(t *testing.T) { + start := "11:00AM" + end := "11:00PM" + correct := "12 hours" + correctBrief := "12h" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestIso8601(t *testing.T) { + start := "2024-06-07T08:00:00Z" + end := "2024-07-08T09:02:03Z" + correct := "4 weeks 3 days 1 hour 2 minutes 3 seconds" + correctBrief := "4W3D1h2m3s" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestTimeZoneOffset(t *testing.T) { + start := "2024-06-07T08:00:00Z" + end := "2024-06-07T08:05:05-05:00" + correct := "5 hours 5 minutes 5 seconds" + correctBrief := "5h5m5s" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestIncludeSpaces(t *testing.T) { + start := "2024-06-07 08:01:02" + end := "2024-06-07 08:02" + correct := "58 seconds" + correctBrief := "58s" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestMicroSeconds(t *testing.T) { + start := "2024-06-07T08:00:00Z" + end := "2024-06-07T08:00:00.000123Z" + correct := "123 microseconds" + correctBrief := "123us" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestMilliSeconds(t *testing.T) { + start := "2024-06-07T08:00:00Z" + end := "2024-06-07T08:01:02.345Z" + correct := "1 minute 2 seconds 345 milliseconds" + correctBrief := "1m2s345ms" + testStartEnd(t, start, end, false, correct) + testStartEnd(t, start, end, true, correctBrief) +} + +func TestDurationHours(t *testing.T) { + from := "11:00AM" + period := "5 hours" + briefPeriod := "5h" + correctAdd := " 16:00:00 " + correctSub := " 06:00:00 " + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := " %H:%M:%S " + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationMillisecondsMicroseconds(t *testing.T) { + from := "2024-01-01 00:00:00" + period := "1 minute 2 seconds 123 milliseconds 456 microseconds" + briefPeriod := "1m2s123ms456us" + correctAdd := "2024-01-01 00:01:02.123456" + correctSub := "2023-12-31 23:58:57.876544" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + correctAdd = correctAdd[:19] + correctSub = correctSub[:19] + ofmt := "%Y-%m-%d %H:%M:%S" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationHoursMinutesSeconds(t *testing.T) { + from := "2024-01-01 00:00:00" + period := "5 hours 5 minutes 5 seconds" + briefPeriod := "5h5m5s" + correctAdd := "2024-01-01 05:05:05" + correctSub := "2023-12-31 18:54:55" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d %H:%M:%S" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationYearsMonthsDays(t *testing.T) { + from := "2000-01-01" + period := "5 years 2 months 10 days" + briefPeriod := "5Y2M10D" + correctAdd := "2005-03-11" + correctSub := "1994-10-22" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationYearsMonthsDaysHoursMinutesSeconds(t *testing.T) { + from := "2024-01-01" + period := "13 years 8 months 28 days 16 hours 15 minutes 15 seconds" + briefPeriod := "13Y8M28D16h15m15s" + correctAdd := "2037-09-29 16:15:15" + correctSub := "2010-04-02 07:44:45" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d %H:%M:%S" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationWeeksDays(t *testing.T) { + from := "2024-01-01" + period := "10 weeks 2 days" + briefPeriod := "10W2D" + correctAdd := "2024-03-13" + correctSub := "2023-10-21" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationMonthsWeeksDays(t *testing.T) { + from := "2024-06-15" + period := "2 months 2 weeks 2 days" + briefPeriod := "2M2W2D" + correctAdd := "2024-08-31" + correctSub := "2024-03-30" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationYearsMonthsWeeksDays(t *testing.T) { + from := "2031-07-12" + period := "2 years 2 months 2 weeks 2 days" + briefPeriod := "2Y2M2W2D" + correctAdd := "2033-09-28" + correctSub := "2029-04-26" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + ofmt := "%Y-%m-%d" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestDurationNanoseconds(t *testing.T) { + from := "2031-07-11 05:00:00" + period := "987654321 nanoseconds" + briefPeriod := "987654321ns" + correctAdd := "2031-07-11 05:00:00.987654321" + correctSub := "2031-07-11 04:59:59.012345679" + testAddSubContains(t, from, period, correctAdd, correctSub) + testAddSubContains(t, from, briefPeriod, correctAdd, correctSub) + correctAdd = correctAdd[:19] + correctSub = correctSub[:19] + ofmt := "%Y-%m-%d %H:%M:%S" + testAddSubOutputFormat(t, from, period, ofmt, correctAdd, correctSub) +} + +func TestWithRepeat(t *testing.T) { + from := "2024-06-28T04:25:41Z" + period := "1M1W1h1m2s" + repeat := 3 + allCorrectAdd := []string{"2024-08-04 05:26:43", "2024-09-11 06:27:45", "2024-10-18 07:28:47"} + allCorrectSub := []string{"2024-05-21 03:24:39", "2024-04-14 02:23:37", "2024-03-07 01:22:35"} + testAddSubWithRepeat(t, from, period, allCorrectAdd, allCorrectSub, repeat) +} + +func TestAddUntil(t *testing.T) { + from := "2024-06-28T04:25:41Z" + period := "1M1W1h1m2s" + until := "2024-10-18 07:28:47" + allCorrectAdd := []string{"2024-08-04 05:26:43", "2024-09-11 06:27:45", "2024-10-18 07:28:47"} + testAddUntil(t, from, until, period, allCorrectAdd) +} + +func TestSubUntil(t *testing.T) { + from := "2024-10-18 07:28:47" + period := "1M1W1h1m2s" + until := "2024-05-28T04:25:41Z" + allCorrectSub := []string{"2024-09-11 06:27:45", "2024-08-04 05:26:43", "2024-06-27 04:25:41"} + testSubUntil(t, from, until, period, allCorrectSub) +} + +func TestRelativeStartEnd(t *testing.T) { + start := "yesterday" + end := "Today" + correct := "1 day" + testStartEnd(t, start, end, false, correct) + + start = "Yesterday" + end = "tomorrow" + correct = "2 days" + testStartEnd(t, start, end, false, correct) + + start = "now" + end = "today" + correct = "0 seconds" + testStartEnd(t, start, end, false, correct) + + start = "today" + end = "tomorrow" + correct = "1 day" + testStartEnd(t, start, end, false, correct) +} + +func TestRelativeUntil(t *testing.T) { + from := carbon.Now().StartOfDay().ToDateTimeString() + period := "7h59m1s" + until := "tomorrow" + allCorrectAdd := []string{"", "", "", ""} + allCorrectAdd[0] = fmt.Sprintf("%s", strings.Replace(from, "00:00:00", "07:59:01", 1)) + allCorrectAdd[1] = fmt.Sprintf("%s", strings.Replace(from, "00:00:00", "15:58:02", 1)) + allCorrectAdd[2] = fmt.Sprintf("%s", strings.Replace(from, "00:00:00", "23:57:03", 1)) + allCorrectAdd[3] = fmt.Sprintf("%s", strings.Replace(from, "00:00:00", "07:56:04", 1)) + allCorrectAdd[3] = fmt.Sprintf("%s", strings.Replace(allCorrectAdd[3], carbon.Now().ToDateString(), carbon.Tomorrow().ToDateString(), 1)) + testAddUntil(t, from, until, period, allCorrectAdd) +} diff --git a/README.md b/README.md index 275b827..928f9f4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,260 @@ # DateTimeMate Golang package and CLI to compute the difference between date, time or duration + +The command-line program, `dtmate` *(along with the golang package)* allows you to answer three types of questions: + +
+1. What is the duration between two different dates and/or times? + +`dtmate diff "2024-06-01 11:22:33" "2024-07-19 21:07:19"` +* answer: `6 weeks 6 days 9 hours 44 minutes 46 seconds` +* answer with the `-b` option: `6W6D9h44m46s` +* start and end can be in various formats, such as: +* * `11:22:33`, `2024-06-01`, `"2024-06-01 11:22:33"`, `2024-06-01T11:22:33.456Z` +
+ +
+2. What is the datetime when adding or subtracting a duration? + +`dtmate dur "2024-06-01 11:22:33" 6W6D9h44m46s -a` +* answer: `2024-04-14 01:37:47 -0400 EDT` +* answer with the `-f "%Y-%m-%d %H:%M:%S"` option: `2024-04-14 01:37:47` +* Duration examples include: +* * `5 minutes 5 seconds or 5m5s` +* * `3 weeks 4 days 5 hours or 3W4D5h` +* * `1 year 2 months 3 days 4 hours 5 minutes 6 second 7 milliseconds 8 microseconds 9 nanoseconds or 1Y2M3D4h5m6s7ms8us9ns` +
+ +
+3. Similar to previous question, but repeats a period multiple times or until a certain date/time is encountered. + +* adding dates, repeat twice: `dtmate dur "2024-06-01 12:00:00" 1h5m10s -r 2 -a` +* subtracting until a date is exceeded: `dtmate dur "12:00:00" 1h5m10s -u "09:48" -s` +
+ +## Installation + +* Library: `go get -u github.com/jftuga/DateTimeMate` +* Command line tool: `go install github.com/jftuga/DateTimeMate/cmd/dtmate@latest` +* * Binaries for all platforms are provided in the [releases](https://github.com/jftuga/DateTimeMate/releases) section. +* Homebrew (MacOS / Linux): +* * `brew tap jftuga/homebrew-tap; brew update; brew install jftuga/tap/DateTimeMate` + +## Library Usage +
+Example 1 - duration between two dates + +Supported date time formats are listed in: https://go.dev/src/time/format.go + +```golang +import "github.com/jftuga/DateTimeMate" + +// example 1 - duration between two dates +start := "2024-06-01" +end := "2024-08-05 00:01:02" +brief := true +diff := DateTimeMate.NewDiff(DateTimeMate.DiffWithStart(start), DateTimeMate.DiffWithEnd(end), + DateTimeMate.DiffWithBrief(brief)) +result, duration, err := diff.CalculateDiff() +if err != nil { ... } +fmt.Println(result, duration) // 9W2D1m2s 1560h1m2s +``` +
+ +
+Example 2 - add a duration + +```go +// example 2 - add a duration and repeat it until the "until" date is exceeded +from := "2024-06-01" +d := "1 year 7 days 6 hours 5 minutes" +until := "2027-06-22 18:15:11" +ofmt := "%Y%m%d.%H%M%S" +dur := DateTimeMate.NewDur(DateTimeMate.DurWithFrom(from), DateTimeMate.DurWithDur(d), + DateTimeMate.DurWithRepeat(0), DateTimeMate.DurWithUntil(until), + DateTimeMate.DurWithOutputFormat(ofmt)) +add, err := dur.Add() +if err != nil { ... } +fmt.Println(add) // [20250608.060500 20260615.121000 20270622.181500] +``` +
+ +See also the [example](cmd/example/main.go) program. + + +## Command Line Usage +
+ +Show + +``` +dtmate: output the difference between date, time or duration + +Usage: + dtmate [command] + +Available Commands: + diff Output the difference between two date/times + dur Output a date/time when given a starting date/time and duration + help Help about any command + +Flags: + -h, --help help for dtmate + -n, --nonewline do not output a newline character + -t, --toggle Help message for toggle + -v, --version version for dtmate + +Use "dtmate [command] --help" for more information about a command. + +Durations: +years months weeks days +hours minutes seconds milliseconds microseconds nanoseconds +example: '1 year 2 months 3 days 4 hours 1 minute 6 seconds' + +Brief Durations: (dates are always uppercase, times are always lowercase) +Y M W D +h m s ms us ns +examples: 1Y2M3W4D5h6m7s8ms9us1ns, '1Y 2M 3W 4D 5h 6m 7s 8ms 9us 1ns' + +Relative Date Shortcuts: +now +today (returns same value as now) +yesterday (exactly 24 hours ahead of the current time) +tomorrow (exactly 24 hours behind the current time) +example: dtmate dur today 7h10m -a -u tomorrow +``` + +
+ +**Note:** The `-i` switch can accept two different types of input: + +1. one line with start and end separated by a comma +2. two lines with start on the first line and end on the second line + +**Note:** The `-n` switch along with `-R` will emit a comma-delimited output + +## Command Line Examples + +
+Show + +```shell +# difference between two times on the same day +$ dtmate diff 12:00:00 15:30:45 +3 hours 30 minutes 45 seconds + +# same input, using brief output +$ dtmate diff 12:00:00 15:30:45 -b +3h30m45s + +# using AM/PM and not 24-hour times +$ dtmate diff "11:00AM" "11:00PM" +12 hours + +# using ISO-8601 dates +$ dtmate diff 2024-06-07T08:00:00Z 2024-06-08T09:02:03Z +1 day 1 hour 2 minutes 3 seconds + +# using timezone offset +$ dtmate diff 2024-06-07T08:00:00Z 2024-06-07T08:05:05-05:00 +5 hours 5 minutes 5 seconds + +# using a format which includes spaces +$ dtmate diff "2024-06-07 08:01:02" "2024-06-07 08:02" +58 seconds + +# using the built-in MacOS date program and do not include a newline character +$ dtmate diff "$(date -R)" "$(date -v+1M -v+30S)" -n +1 minute 30 seconds% + +# using the cross-platform date program, ending time starting first +$ dtmate diff "$(date)" 2020 +-4 years 24 weeks 1 day 7 hours 21 minutes 53 seconds + +# same input, using brief output +$ dtmate diff "$(date)" 2020 -b +-4Y24W1D7h21m53s + +# using microsecond formatting +$ dtmate diff 2024-06-07T08:00:00Z 2024-06-07T08:00:00.000123Z +123 microseconds + +# using millisecond formatting, adding -b returns: 1m2s345ms +$ dtmate diff 2024-06-07T08:00:00Z 2024-06-07T08:01:02.345Z +1 minute 2 seconds 345 milliseconds + +# read from STDIN in CSV format and do not include a newline character +$ dtmate diff -i -n +15:16:15,15:17 +45 seconds% + +# same as above, include newline character +$ echo 15:16:15,15:17 | dtmate -i +45 seconds + +# read from STDIN with start on first line and end on second line +$ printf "15:16:15\n15:17:20" | dtmate diff -i +1 minute 5 seconds + +# add time +# can also use "years", "months", "weeks", "days" +$ dtmate dur 2024-01-01 "1 hour 30 minutes 45 seconds" -a +2024-01-01 01:30:45 -0500 EST + +# subtract time +# can also use "milliseconds", "microseconds" +$ dtmate dur "2024-01-02 01:02:03" "1 day 1 hour 2 minutes 3 seconds" -s +2024-01-01 00:00:00 -0500 EST + +# output multiple occurrences: add 5 weeks, for 3 intervals +$ dtmate dur "2024-01-02" "5W" -r 3 -a +2024-02-06 00:00:00 -0500 EST +2024-03-12 00:00:00 -0400 EDT +2024-04-16 00:00:00 -0400 EDT + +# repeat until a certain datetime is encountered: subtract 5 minutes until 15:00 +$ dtmate dur 15:20 5m -u 15:00 -s +2024-06-30 15:15:00 -0400 EDT +2024-06-30 15:10:00 -0400 EDT +2024-06-30 15:05:00 -0400 EDT +2024-06-30 15:00:00 -0400 EDT + +# use relative date until tomorrow +$ dtmate dur today 7h10m -u tomorrow -a +2024-07-03 14:29:28 -0400 EDT +2024-07-03 21:39:28 -0400 EDT +2024-07-04 04:49:28 -0400 EDT + +# use relative start date with brief output +$ dtmate diff today 2024-07-07 -b +3D16h38m47s + +# set the output format +$ dtmate dur "2024-07-01 12:00:00" 1W2D3h4m5s -a -f "%Y%m%d.%H%M%S" +20240710.150405 +``` +
+ +## LICENSE + +[MIT LICENSE](LICENSE) + +## Acknowledgements + +
+Imported Modules + +* carbon - https://github.com/golang-module/carbon +* cobra - https://github.com/spf13/cobra +* durafmt - https://github.com/hako/durafmt +* parsetime - https://github.com/tkuchiki/parsetime +* strftime - https://github.com/lestrrat-go/strftime + +
+ +## Disclosure Notification + +This program is my own original idea and was completely developed +on my own personal time, for my own personal benefit, and on my +personally owned equipment. + diff --git a/cmd/dtmate/cmd/diff.go b/cmd/dtmate/cmd/diff.go new file mode 100644 index 0000000..8e9c625 --- /dev/null +++ b/cmd/dtmate/cmd/diff.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "github.com/jftuga/DateTimeMate" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// diffCmd represents the diff command +var diffCmd = &cobra.Command{ + Use: "diff [start] [end]", + Short: "Output the difference between two date/times", + Args: func(cmd *cobra.Command, args []string) error { + if optDiffReadFromStdin { + if len(args) == 0 { + return nil + } else { + return errors.New("invalid number of arguments") + } + } + if len(args) != 2 { + return errors.New("requires two arguments: start, end date/times") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + if optDiffReadFromStdin { + start, end := getInput() + outputDiff(start, end, optDiffBrief) + return + } + outputDiff(args[0], args[1], optDiffBrief) + }, +} + +var optDiffBrief bool +var optDiffReadFromStdin bool + +func init() { + rootCmd.AddCommand(diffCmd) + diffCmd.Flags().BoolVarP(&optDiffBrief, "brief", "b", false, "output in brief format, such as: 1Y2M3D4h5m6s7ms8us9ns") + diffCmd.Flags().BoolVarP(&optDiffReadFromStdin, "stdin", "i", false, "read from STDIN instead of using -s/-e") +} + +// either read one line containing a comma, then split start and end on this +// or read two lines with start on line one and end on line two +func getInput() (string, string) { + input := bufio.NewScanner(os.Stdin) + input.Scan() + line := input.Text() + if strings.Contains(line, ",") { + split := strings.Split(line, ",") + if len(split) != 2 { + fmt.Fprintf(os.Stderr, "invalid stdin input: %s\n", line) + os.Exit(1) + } + return split[0], split[1] + } + input.Scan() + end := input.Text() + return line, end +} + +// outputDiff compute the duration between two dates, times, and/or date/times +func outputDiff(start, end string, brief bool) { + diff := DateTimeMate.NewDiff(DateTimeMate.DiffWithStart(start), DateTimeMate.DiffWithEnd(end), DateTimeMate.DiffWithBrief(brief)) + result, _, err := diff.CalculateDiff() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if optRootNoNewline { + fmt.Print(result) + } else { + fmt.Println(result) + } +} diff --git a/cmd/dtmate/cmd/dur.go b/cmd/dtmate/cmd/dur.go new file mode 100644 index 0000000..dbbd63f --- /dev/null +++ b/cmd/dtmate/cmd/dur.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "github.com/jftuga/DateTimeMate" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// durCmd represents the dur command +var durCmd = &cobra.Command{ + Use: "dur [from] [duration]", + Short: "Output a date/time when given a starting date/time and duration", + Args: cobra.MatchAll(cobra.ExactArgs(2)), + Run: func(cmd *cobra.Command, args []string) { + outputDur(args[0], args[1], optDurUntil, optDurFormat, optDurRepeat) + }, +} + +var ( + //optDurFrom string + optDurAdd bool + optDurSub bool + optDurUntil string + optDurFormat string + optDurRepeat int +) + +func init() { + rootCmd.AddCommand(durCmd) + durCmd.Flags().BoolVarP(&optDurAdd, "add", "a", false, "add: a duration to use with -f, such as '1D2h3s' or '1 day 2 hours 3 seconds'") + durCmd.Flags().BoolVarP(&optDurSub, "sub", "s", false, "subtract: a duration to use with -f, such as '5 months 4 weeks 3 days'") + durCmd.Flags().StringVarP(&optDurUntil, "until", "u", "", "repeat duration until this date/time is exceeded") + durCmd.Flags().StringVarP(&optDurFormat, "format", "f", "", "output results with strftime formatting") + durCmd.Flags().IntVarP(&optDurRepeat, "repeat", "r", 0, "repeat the -a or -s duration this number of times (mutually exclusive with -u)") + durCmd.MarkFlagsOneRequired("add", "sub") + durCmd.MarkFlagsMutuallyExclusive("add", "sub") + durCmd.MarkFlagsMutuallyExclusive("repeat", "until") +} + +func outputDur(from, duration, until, format string, repeat int) { + dur := DateTimeMate.NewDur( + DateTimeMate.DurWithFrom(from), + DateTimeMate.DurWithDur(duration), + DateTimeMate.DurWithUntil(until), + DateTimeMate.DurWithRepeat(repeat), + DateTimeMate.DurWithOutputFormat(format)) + + var allResults []string + var err error + if optDurAdd { + allResults, err = dur.Add() + } else { + allResults, err = dur.Sub() + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + delim := "\n" + if optRootNoNewline { + delim = "," + } + output := strings.Join(allResults, delim) + fmt.Print(output) + if !optRootNoNewline { + fmt.Println() + } +} diff --git a/cmd/dtmate/cmd/root.go b/cmd/dtmate/cmd/root.go new file mode 100644 index 0000000..8815c9a --- /dev/null +++ b/cmd/dtmate/cmd/root.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + DateTimeMate "github.com/jftuga/DateTimeMate" + "github.com/spf13/cobra" + "os" +) + +const extendedHelp string = ` +Durations: +years months weeks days +hours minutes seconds milliseconds microseconds nanoseconds +example: '1 year 2 months 3 days 4 hours 1 minute 6 seconds' + +Brief Durations: (dates are always uppercase, times are always lowercase) +Y M W D +h m s ms us ns +examples: 1Y2M3W4D5h6m7s8ms9us1ns, '1Y 2M 3W 4D 5h 6m 7s 8ms 9us 1ns' + +Relative Date Shortcuts: +now +today (returns same value as now) +yesterday (exactly 24 hours ahead of the current time) +tomorrow (exactly 24 hours behind the current time) +example: dtmate dur today 7h10m -a -u tomorrow +` + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "dtmate", + Short: "dtmate: output the difference between date, time or duration", + Version: DateTimeMate.ModVersion, +} + +var optRootNoNewline bool + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + rootCmd.PersistentFlags().BoolVarP(&optRootNoNewline, "nonewline", "n", false, "do not output a newline character") + versionTemplate := fmt.Sprintf("dtmate version %s\n%s\n", DateTimeMate.ModVersion, DateTimeMate.ModUrl) + rootCmd.SetVersionTemplate(versionTemplate) + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SetUsageTemplate(rootCmd.UsageTemplate() + extendedHelp) +} diff --git a/cmd/dtmate/main.go b/cmd/dtmate/main.go new file mode 100644 index 0000000..9f8da08 --- /dev/null +++ b/cmd/dtmate/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/jftuga/DateTimeMate/cmd/dtmate/cmd" + +func main() { + cmd.Execute() +} diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..205084a --- /dev/null +++ b/cmd/example/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "github.com/jftuga/DateTimeMate" + "os" +) + +func main() { + fmt.Println() + + start := "2024-06-01" + end := "2024-08-05 00:01:02" + brief := true + diff := DateTimeMate.NewDiff( + DateTimeMate.DiffWithStart(start), + DateTimeMate.DiffWithEnd(end), + DateTimeMate.DiffWithBrief(brief)) + fmt.Println(diff) + fmt.Println("===================================================") + + result, duration, err := diff.CalculateDiff() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("duration:", result, "=>", duration) + fmt.Println("===================================================") + + from := "2024-06-01" + d := "1 year 7 days 6 hours 5 minutes" + until := "2027-06-22 18:15:11" + ofmt := "%Y%m%d.%H%M%S" + dur := DateTimeMate.NewDur( + DateTimeMate.DurWithFrom(from), + DateTimeMate.DurWithDur(d), + DateTimeMate.DurWithRepeat(0), + DateTimeMate.DurWithUntil(until), + DateTimeMate.DurWithOutputFormat(ofmt)) + fmt.Println("duration:", dur) + fmt.Println("===================================================") + + add1, err := dur.Add() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Add1: ", add1) + fmt.Println("===================================================") + dur.Until = "" + dur.Repeat = 3 + + add2, err := dur.Add() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Add2: ", add2) + fmt.Println("===================================================") + + until = "2020-05-02 23:41:00" + ofmt = "%v %T" + dur = DateTimeMate.NewDur( + DateTimeMate.DurWithFrom(from), + DateTimeMate.DurWithDur(d), + DateTimeMate.DurWithUntil(until), + DateTimeMate.DurWithOutputFormat(ofmt)) + sub1, err := dur.Sub() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Sub1: ", sub1) +} diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..fd225a5 --- /dev/null +++ b/diff.go @@ -0,0 +1,91 @@ +package DateTimeMate + +import ( + "fmt" + "github.com/golang-module/carbon/v2" + "github.com/hako/durafmt" + "github.com/tkuchiki/parsetime" + "time" +) + +type Diff struct { + Start string + End string + Brief bool +} + +type OptionsDiff func(*Diff) + +func NewDiff(options ...OptionsDiff) *Diff { + diff := &Diff{} + for _, opt := range options { + opt(diff) + } + return diff +} + +func DiffWithStart(start string) OptionsDiff { + return func(opt *Diff) { + opt.Start = start + } +} + +func DiffWithEnd(end string) OptionsDiff { + return func(opt *Diff) { + opt.End = end + } +} + +func DiffWithBrief(brief bool) OptionsDiff { + return func(opt *Diff) { + opt.Brief = brief + } +} + +func (diff *Diff) String() string { + return fmt.Sprintf("start:%v end:%v brief:%v", diff.Start, diff.End, diff.Brief) +} + +// CalculateDiff return the time difference and also set dt.Diff +// first try to parse with carbon, fallback to parsing with now if carbon fails to parse +func (diff *Diff) CalculateDiff() (string, time.Duration, error) { + var start, end time.Time + + alpha := carbon.Parse(convertRelativeDateToActual(diff.Start)) + if alpha.Error != nil { + // fmt.Println("alpha:", alpha.Error) + p, err := parsetime.NewParseTime() + if err != nil { + return "", 0, err + } + start, err = p.Parse(diff.Start) + if err != nil { + return "", 0, err + } + } else { + start = alpha.StdTime() + } + + omega := carbon.Parse(convertRelativeDateToActual(diff.End)) + if omega.Error != nil { + // fmt.Println("omega:", omega.Error) + p, err := parsetime.NewParseTime() + if err != nil { + return "", 0, err + } + end, err = p.Parse(diff.End) + if err != nil { + return "", 0, err + } + } else { + end = omega.StdTime() + } + + duration := end.Sub(start) + parsed := durafmt.Parse(duration) + difference := fmt.Sprintf("%v", parsed) + if diff.Brief { + difference = shrinkPeriod(difference) + } + return difference, duration, nil +} diff --git a/dur.go b/dur.go new file mode 100644 index 0000000..40607ab --- /dev/null +++ b/dur.go @@ -0,0 +1,362 @@ +package DateTimeMate + +import ( + "fmt" + "github.com/golang-module/carbon/v2" + "github.com/lestrrat-go/strftime" + "github.com/tkuchiki/parsetime" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +// used for Dur.Op +const ( + Add = iota + Sub +) + +type Dur struct { + From string + Op int + Period string + Repeat int + Until string + OutputFormat string +} + +type OptionsDur func(*Dur) + +const ( + expanded string = `(\d+)\s(years?|months?|weeks?|days?|hours?|minutes?|seconds?|milliseconds?|microseconds?|nanoseconds?)` + wordsOnly string = `\b[a-zA-Z]+\b` + dupMsg string = "Hint: duplicate durations not allowed; dates in uppercase; times in lowercase" +) + +var carbonFuncs = map[string]interface{}{ + "year": [2]interface{}{carbon.Carbon.AddYears, carbon.Carbon.SubYears}, + "month": [2]interface{}{carbon.Carbon.AddMonths, carbon.Carbon.SubMonths}, + "week": [2]interface{}{carbon.Carbon.AddWeeks, carbon.Carbon.SubWeeks}, + "day": [2]interface{}{carbon.Carbon.AddDays, carbon.Carbon.SubDays}, + "hour": [2]interface{}{carbon.Carbon.AddHours, carbon.Carbon.SubHours}, + "minute": [2]interface{}{carbon.Carbon.AddMinutes, carbon.Carbon.SubMinutes}, + "second": [2]interface{}{carbon.Carbon.AddSeconds, carbon.Carbon.SubSeconds}, + "millisecond": [2]interface{}{carbon.Carbon.AddMilliseconds, carbon.Carbon.SubMilliseconds}, + "microsecond": [2]interface{}{carbon.Carbon.AddMicroseconds, carbon.Carbon.SubMicroseconds}, + "nanosecond": [2]interface{}{carbon.Carbon.AddNanoseconds, carbon.Carbon.SubNanoseconds}, +} + +var expandedRegexp = regexp.MustCompile(expanded) + +func NewDur(options ...OptionsDur) *Dur { + dur := &Dur{} + for _, opt := range options { + opt(dur) + } + return dur +} + +func DurWithFrom(from string) OptionsDur { + return func(dur *Dur) { + dur.From = from + } +} + +func DurWithOp(op int) OptionsDur { + return func(dur *Dur) { + dur.Op = op + } +} + +func DurWithDur(d string) OptionsDur { + return func(dur *Dur) { + dur.Period = d + } +} + +func DurWithRepeat(repeat int) OptionsDur { + return func(dur *Dur) { + dur.Repeat = repeat + } +} +func DurWithUntil(until string) OptionsDur { + return func(dur *Dur) { + dur.Until = until + } +} + +func DurWithOutputFormat(outputFormat string) OptionsDur { + return func(dur *Dur) { + dur.OutputFormat = outputFormat + } +} + +func (dur *Dur) String() string { + //return fmt.Sprintf("From: %v Period:%v Op:%v Repeat:%v Until:%v OutputFormat:%v", dur.From, dur.Period, dur.Op, dur.Repeat, dur.Until, dur.OutputFormat) + return fmt.Sprintf("%v,%v,%v,%v,%v,%v", dur.From, dur.Period, dur.Op, dur.Repeat, dur.Until, dur.OutputFormat) +} + +func (dur *Dur) Add() ([]string, error) { + return dur.addOrSub(Add) +} + +func (dur *Dur) Sub() ([]string, error) { + return dur.addOrSub(Sub) +} + +func (dur *Dur) addOrSub(op int) ([]string, error) { + if dur.Repeat > 0 && dur.Until != "" { + return nil, fmt.Errorf("repeat & until are mutually exclusive") + } + + var all []string + var err error + if dur.Repeat == 0 && dur.Until == "" { + var c string + c, err = calculate(dur.From, dur.Period, op) + if err != nil { + return nil, err + } + all = []string{c} + } else if dur.Repeat > 0 && dur.Until == "" { + from := dur.From + for i := 0; i < dur.Repeat; i++ { + from, err = calculate(from, dur.Period, op) + if err != nil { + return nil, err + } + all = append(all, from) + } + } else if dur.Repeat == 0 && dur.Until != "" { + var f, u time.Time + var err error + + until := convertRelativeDateToActual(dur.Until) + p, err := parsetime.NewParseTime() + if err != nil { + return nil, err + } + u, err = p.Parse(until) + if err != nil { + return nil, err + } + + from := convertRelativeDateToActual(dur.From) + for { + from, err = calculate(from, dur.Period, op) + if err != nil { + return nil, err + } + + p, err := parsetime.NewParseTime() + if err != nil { + return nil, err + } + f, err = p.Parse(from) + if err != nil { + return nil, err + } + + if Add == op { // FIXME + if f.After(u) { + break + } + } else { + if f.Before(u) { + break + } + } + all = append(all, from) + } + } + + if len(dur.OutputFormat) > 0 && len(all) > 0 { + var allWithFormat []string + for _, a := range all { + formatted, err := dur.setOutputFormat(a) + if err != nil { + return nil, err + } + allWithFormat = append(allWithFormat, formatted) + } + return allWithFormat, nil + } + return all, nil +} + +//func (dur *Period) Sub() (string, error) { +// return calculate(dur.From, dur.Period, Sub) +//} + +func (dur *Dur) setOutputFormat(arg string) (string, error) { + f, err := strftime.New(dur.OutputFormat) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + p, err := parsetime.NewParseTime() + if err != nil { + return "", err + } + s, err := p.Parse(arg) + if err != nil { + return "", err + } + output := f.FormatString(s) + return output, nil +} + +func calculate(from, period string, index int) (string, error) { + periodMatches := expandedRegexp.FindAllStringSubmatch(period, -1) + if len(periodMatches) == 0 { + // brief format is being used so first expand it to the long format + period, err := expandPeriod(period) + if nil != err { + return "", fmt.Errorf("%v", err) + } + periodMatches = expandedRegexp.FindAllStringSubmatch(period, -1) + if len(periodMatches) == 0 { + return "", fmt.Errorf("[validatePeriod] Invalid duration: %s", period) + } + } + + from = convertRelativeDateToActual(from) + p, err := parsetime.NewParseTime() + if err != nil { + return "", err + } + f, err := p.Parse(from) + if err != nil { + return "", err + } + + to := carbon.CreateFromStdTime(f) + if to.Error != nil { + return "", to.Error + } + err = validatePeriod(period) + if err != nil { + return "", err + } + + for i := range periodMatches { + amount := periodMatches[i][1] + num, err := strconv.Atoi(amount) + if err != nil { + return "", err + } + word := periodMatches[i][2] + // to understand this line of code, read: ChatGPT_Explanation.md + to = carbonFuncs[removeTrailingS(word)].([2]interface{})[index].(func(carbon.Carbon, int) carbon.Carbon)(to, num) + // fmt.Printf(" to: %v | %v | %v\n", num, word, to) + } + return to.ToString(), nil +} + +// validatePeriod ensure all words in "period" are a valid time duration +func validatePeriod(period string) error { + wordsOnlyRe := regexp.MustCompile(wordsOnly) + matches := wordsOnlyRe.FindAllString(period, -1) + for _, word := range matches { + // fmt.Println("word:", word) + _, ok := carbonFuncs[removeTrailingS(word)] + if !ok { + return fmt.Errorf("[validatePeriod] Invalid period: %s", word) + } + } + return nil +} + +// removeTrailingS convert plural to singular, such as "hours" to "hour" +func removeTrailingS(s string) string { + if len(s) > 0 && s[len(s)-1] == 's' { + return s[:len(s)-1] + } + return s +} + +// shrinkPeriod convert a period into a brief period +// only allow one replacement per each period +// Ex: 1 hour 2 minutes 3 seconds => 1h2m3s +// FIXME: almost redundant code for plural & singular -- try to fix with strings.NewReplacer +func shrinkPeriod(period string) string { + // plural + period = strings.Replace(period, "nanoseconds", "ns", 1) + period = strings.Replace(period, "microseconds", "us", 1) + period = strings.Replace(period, "milliseconds", "ms", 1) + period = strings.Replace(period, "seconds", "s", 1) + period = strings.Replace(period, "minutes", "m", 1) + period = strings.Replace(period, "hours", "h", 1) + period = strings.Replace(period, "days", "D", 1) + period = strings.Replace(period, "weeks", "W", 1) + period = strings.Replace(period, "months", "M", 1) + period = strings.Replace(period, "years", "Y", 1) + + // singular + period = strings.Replace(period, "nanosecond", "ns", 1) + period = strings.Replace(period, "microsecond", "us", 1) + period = strings.Replace(period, "millisecond", "ms", 1) + period = strings.Replace(period, "second", "s", 1) + period = strings.Replace(period, "minute", "m", 1) + period = strings.Replace(period, "hour", "h", 1) + period = strings.Replace(period, "day", "D", 1) + period = strings.Replace(period, "week", "W", 1) + period = strings.Replace(period, "month", "M", 1) + period = strings.Replace(period, "year", "Y", 1) + + return strings.ReplaceAll(period, " ", "") +} + +// expandPeriod convert a brief style period into a long period +// only allow one replacement per each period +// Ex: 1h2m3s => 1 hour 2 minutes 3 seconds +func expandPeriod(period string) (string, error) { + // a direct string replace will not work because some + // periods have overlapping strings, such as 's' with 'ms, 'us', 'ns' + // therefore convert each period to a unique string first + s := period + s = strings.Replace(s, "ns", "α", 1) + s = strings.Replace(s, "us", "β", 1) + s = strings.Replace(s, "µs", "β", 1) + s = strings.Replace(s, "ms", "γ", 1) + s = strings.Replace(s, "s", "δ", 1) + s = strings.Replace(s, "m", "ε", 1) + s = strings.Replace(s, "h", "ζ", 1) + s = strings.Replace(s, "D", "η", 1) + s = strings.Replace(s, "W", "θ", 1) + s = strings.Replace(s, "M", "ι", 1) + s = strings.Replace(s, "Y", "λ", 1) + + // now convert from the unique string back to the corresponding duration + p := s + p = strings.Replace(p, "α", " nanoseconds ", 1) + p = strings.Replace(p, "β", " microseconds ", 1) + p = strings.Replace(p, "γ", " milliseconds ", 1) + p = strings.Replace(p, "δ", " seconds ", 1) + p = strings.Replace(p, "ε", " minutes ", 1) + p = strings.Replace(p, "ζ", " hours ", 1) + p = strings.Replace(p, "η", " days ", 1) + p = strings.Replace(p, "θ", " weeks ", 1) + p = strings.Replace(p, "ι", " months ", 1) + p = strings.Replace(p, "λ", " years ", 1) + + // ensure each time & period was successfully replaced + // len of Fields should always be even because is part + // of the period is a two element tuple of + // a numeric amount and a duration + words := strings.Fields(p) + if len(words)%2 == 1 { + return "", fmt.Errorf("[expandPeriod] Invalid period: %s. %s", period, dupMsg) + } + + // check that every other element is a number + for i := 0; i < len(words); i += 2 { + _, err := strconv.Atoi(words[i]) + if err != nil { + return "", fmt.Errorf("[expandPeriod] %v. %s", err, dupMsg) + } + } + return p, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3c54933 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/jftuga/DateTimeMate + +go 1.22.4 + +require ( + github.com/golang-module/carbon/v2 v2.3.12 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b + github.com/lestrrat-go/strftime v1.0.6 + github.com/spf13/cobra v1.8.1 + github.com/tkuchiki/parsetime v0.3.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tkuchiki/go-timezone v0.2.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f617856 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crackcomm/go-clitable v0.0.0-20151121230230-53bcff2fea36/go.mod h1:XiV36mPegOHv+dlkCSCazuGdQR2BUTgIZ2FKqTTHles= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-module/carbon/v2 v2.3.12 h1:VC1DwN1kBwJkh5MjXmTFryjs5g4CWyoM8HAHffZPX/k= +github.com/golang-module/carbon/v2 v2.3.12/go.mod h1:HNsedGzXGuNciZImYP2OMnpiwq/vhIstR/vn45ib5cI= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= +github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= +github.com/lestrrat-go/strftime v1.0.6 h1:CFGsDEt1pOpFNU+TJB0nhz9jl+K0hZSLE205AhTIGQQ= +github.com/lestrrat-go/strftime v1.0.6/go.mod h1:f7jQKgV5nnJpYgdEasS+/y7EsTb8ykN2z68n3TtcTaw= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/goveralls v0.0.9/go.mod h1:FRbM1PS8oVsOe9JtdzAAXM+DsvDMMHcM1C7drGJD8HY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkuchiki/go-timezone v0.2.2/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY= +github.com/tkuchiki/go-timezone v0.2.3 h1:D3TVdIPrFsu9lxGxqNX2wsZwn1MZtTqTW0mdevMozHc= +github.com/tkuchiki/go-timezone v0.2.3/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY= +github.com/tkuchiki/parsetime v0.3.0 h1:cvblFQlPeAPJL8g6MgIGCHnnmHSZvluuY+hexoZCNqc= +github.com/tkuchiki/parsetime v0.3.0/go.mod h1:OJkQmIrf5Ao7R+WYIdITPOfDVj8LmnHGCfQ8DTs3LCA= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=