~kota/tide

tide/main.go -rw-r--r-- 3.5 KiB
9875dc8fDakota Walsh bump go-isatty 11 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// tides - Print tidal data information for Aotearoa
// Copyright (C) 2021 Dakota Walsh
// GPL3+ See LICENSE in this repo for details.
package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"time"
	"unicode"

	"github.com/mattn/go-isatty"
)

var out io.Writer = os.Stdout // modified during testing

// TZ is the timezone for the imported times.
const TZ = "NZ"

// Tide represents a LINZ tidal height at a specific time.
type Tide struct {
	Time   time.Time
	Height float64
}

// getInput returns a File using either Stdin or the first passed argument
func getInput() (*os.File, error) {
	args := os.Args[1:]
	if len(args) == 0 {
		return os.Stdin, nil
	} else {
		f, err := os.Open(args[0])
		return f, err
	}
}

// getRecords reads and parses a csv file from LINZ with tidal data into
// [][]string and skips the first 3 metadata lines.
func getRecords(f *os.File) ([][]string, error) {
	reader := csv.NewReader(f)
	reader.FieldsPerRecord = -1 // allows for variable number of fields
	// skip the first 3 lines
	for i := 0; i < 3; i++ {
		reader.Read()
	}
	records, err := reader.ReadAll()
	return records, err
}

// parseRecord reads a []string representing a line in the csv file and adds
// them to a slice of Tides in order from oldest to newest.
func parseRecord(tides *[]Tide, record []string) error {
	// Each record represents a single date, but contains multiple tides at
	// different times.
	date, err := getDate(record[3], record[2], record[0])
	if err != nil {
		return err
	}
	for r := 4; r < len(record); r += 2 {
		// some days have less tides
		if record[r] == "" {
			break
		}
		duration, err := getDuration(record[r])
		t := date.Add(duration)
		if err != nil {
			return err
		}
		height, err := strconv.ParseFloat(record[r+1], 64)
		if err != nil {
			return err
		}
		tide := Tide{t, height}
		*tides = append(*tides, tide)
	}
	return nil
}

// getDate takes the year, month, day strings from the CSV file and returns a
// time.Time value with the correct timezone.
func getDate(year, month, day string) (time.Time, error) {
	loc, _ := time.LoadLocation(TZ) // Timezone isn't included in the CSV
	month = fmt.Sprintf("%02s", month)
	day = fmt.Sprintf("%02s", day)
	t, err := time.ParseInLocation("20060102", year+month+day, loc)
	return t, err
}

// getDuration takes a string in the hh:mm format and returns a time.Duration.
// The string is split into slice t and then formatted into the
// time.ParseDuration format.
func getDuration(s string) (time.Duration, error) {
	f := func(c rune) bool {
		return !unicode.IsLetter(c) && !unicode.IsNumber(c)
	}
	t := strings.FieldsFunc(s, f)
	duration, err := time.ParseDuration(fmt.Sprintf("%vh%vm", t[0], t[1]))
	return duration, err
}

func main() {
	// Get a File from Stdin or a passed argument
	f, err := getInput()
	if err != nil {
		fmt.Printf("%v\n", err)
		os.Exit(1)
	}

	// Read csv File and store [][]string records
	records, err := getRecords(f)
	if err != nil {
		fmt.Printf("%v\n", err)
		os.Exit(1)
	}

	// Convert [][]string records to []Tide
	var tides []Tide
	for _, record := range records {
		err := parseRecord(&tides, record)
		if err != nil {
			fmt.Printf("%v\n", err)
			os.Exit(1)
		}
	}

	// Calculate and print out tide data. Different output if printing to
	// non-terminal.
	now := time.Now()
	if isatty.IsTerminal(os.Stdout.Fd()) {
		for i, v := range tides {
			if v.Time.After(now) {
				displayTerm(i, &tides, now)
				break
			}
		}
	} else {
		for i, v := range tides {
			if v.Time.After(now) {
				displaySimple(i, &tides, now)
				break
			}
		}
	}
}