Some refactoring; DB functionality
9 files changed, 544 insertions(+), 97 deletions(-)

M README.md
A => cal.go
A => cal_test.go
A => db.go
A => db_test.go
M go.mod
M go.sum
M main.go
A => main_test.go
M README.md +8 -2
@@ 32,12 32,18 @@ Design
 
 icsmailer stands on the shoulders of:
 
-- https://github.com/arran4/golang-ical
-- https://github.com/jordan-wright/email
+- https://github.com/arran4/golang-ical, for managing ICS data
+- https://github.com/cosiner/flag, for it's flag management[^1]
+- https://github.com/gofrs/flock, for making the DB multi-process-safe
+ 
  
 Alts 
 -----
 
+- https://github.com/juju/fslock
 - https://github.com/prologic/bitcask
 - https://github.com/emersion/go-message
 - https://github.com/timshannon/bolthold
+
+
+[^1]: Over the years, I've tried 17 different flag libraries, if you include the stdlib `flag`. Lately, I've been using @cosiners. While I do not prefer the "parse-into-structs" model, @cosiner's library is a nice compromize between features, size (2300 LOC), and dependencies (1, another 600 LOC @cosiner utility package). @thatisuday's commando is a close second (1447 LOC, 1 utility dep), but I find it more verbose and it doesn't support 12-factor-style environment parameters.

          
A => cal.go +59 -0
@@ 0,0 1,59 @@ 
+package main
+
+import (
+	"io"
+	"io/fs"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+type Calendar interface {
+	Stats() CalStats
+	ForEachIcs(func(io.Reader) error)
+}
+
+type CalStats struct {
+	Entries int
+}
+
+func NewCal(path string) Calendar {
+	return calendar{path}
+}
+
+type calendar struct {
+	path string
+}
+
+func (c calendar) Stats() CalStats {
+	files, err := ioutil.ReadDir(c.path)
+	if err != nil {
+		return CalStats{}
+	}
+	return CalStats{
+		Entries: len(files),
+	}
+}
+
+func (c calendar) ForEachIcs(f func(io.Reader) error) {
+	// drop through to operate on calendar
+	filepath.WalkDir(c.path, func(p string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return nil
+		}
+		if strings.HasPrefix(p, ".") {
+			return nil
+		}
+		if !d.IsDir() && strings.HasSuffix(d.Name(), ".ics") {
+			in, err := os.Open(p)
+			if err != nil {
+				return err
+			}
+			if err = f(in); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}

          
A => cal_test.go +74 -0
@@ 0,0 1,74 @@ 
+package main
+
+import (
+	"io"
+	"reflect"
+	"testing"
+)
+
+func TestNewCal(t *testing.T) {
+	type args struct {
+		path string
+	}
+	tests := []struct {
+		name string
+		args args
+		want Calendar
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := NewCal(tt.args.path); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("NewCal() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_calendar_Stats(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		want   CalStats
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			c := calendar{
+				path: tt.fields.path,
+			}
+			if got := c.Stats(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("calendar.Stats() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_calendar_ForEachIcs(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	type args struct {
+		f func(io.Reader) error
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			c := calendar{
+				path: tt.fields.path,
+			}
+			c.ForEachIcs(tt.args.f)
+		})
+	}
+}

          
A => db.go +119 -0
@@ 0,0 1,119 @@ 
+package main
+
+import (
+	"context"
+	"encoding/csv"
+	"errors"
+	"fmt"
+	"github.com/gofrs/flock"
+	"os"
+	"strings"
+	"time"
+)
+
+type Db interface {
+	Stats() DbStats
+	UpdateInvites(string, []string) error
+}
+
+// TODO fill in DbStats
+type DbStats struct {
+	Entries int
+}
+
+type db struct {
+	path string
+}
+
+func NewDb(path string) Db {
+	return db{path}
+}
+
+// TODO implement dbStats()
+func (d db) Stats() DbStats {
+	rs, err := d.readDb()
+	if err != nil {
+		return DbStats{}
+	}
+
+	return DbStats{
+		Entries: len(rs),
+	}
+}
+
+// persistInvites saves the invite list of the ICS UUID to the database.
+// It replaces any existing record for that UUID.
+func (d db) UpdateInvites(id string, as []string) error {
+	sas := strings.Join(as, ",")
+	// Lock the database
+	ln := d.path + ".lock"
+	lock := flock.New(ln)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	success, err := lock.TryLockContext(ctx, 100*time.Millisecond)
+	if !success {
+		msg := fmt.Sprintf("Database appears to be locked by another process. Try removing %s\n", ln)
+		return errors.New(msg)
+	}
+	if err != nil {
+		return err
+	}
+	defer lock.Unlock()
+
+	rs, err := d.readDb()
+	if err != nil {
+		return err
+	}
+
+	var found bool
+	for _, r := range rs {
+		if r[0] == id {
+			r[1] = sas
+		}
+	}
+	if !found {
+		rs = append(rs, []string{id, sas})
+	}
+
+	err = d.save(rs)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (d db) save(rs [][]string) error {
+	// Write the DB back out
+	foutn := d.path + ".tmp"
+	fout, err := os.Create(foutn)
+	if err != nil {
+		return err
+	}
+	defer fout.Close()
+	csvw := csv.NewWriter(fout)
+	err = csvw.WriteAll(rs)
+	if err != nil {
+		return err
+	}
+	csvw.Flush()
+	return nil
+}
+
+func (d db) readDb() ([][]string, error) {
+	// Read the DB
+	fin, err := os.Open(d.path)
+	if err != nil {
+		return nil, err
+	}
+	defer fin.Close()
+	csvr := csv.NewReader(fin)
+	csvr.Comma = '\t'
+
+	// Update or add the changed record
+	rs, err := csvr.ReadAll()
+	if err != nil {
+		return nil, err
+	}
+	return rs, nil
+}

          
A => db_test.go +133 -0
@@ 0,0 1,133 @@ 
+package main
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestNewDb(t *testing.T) {
+	type args struct {
+		path string
+	}
+	tests := []struct {
+		name string
+		args args
+		want Db
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := NewDb(tt.args.path); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("NewDb() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_db_Stats(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		want   DbStats
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := db{
+				path: tt.fields.path,
+			}
+			if got := d.Stats(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("db.Stats() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_db_UpdateInvites(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	type args struct {
+		id string
+		as []string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := db{
+				path: tt.fields.path,
+			}
+			if err := d.UpdateInvites(tt.args.id, tt.args.as); (err != nil) != tt.wantErr {
+				t.Errorf("db.UpdateInvites() %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func Test_db_save(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	type args struct {
+		rs [][]string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := db{
+				path: tt.fields.path,
+			}
+			if err := d.save(tt.args.rs); (err != nil) != tt.wantErr {
+				t.Errorf("db.save() %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func Test_db_readDb(t *testing.T) {
+	type fields struct {
+		path string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		want    [][]string
+		wantErr bool
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := db{
+				path: tt.fields.path,
+			}
+			got, err := d.readDb()
+			if (err != nil) != tt.wantErr {
+				t.Errorf("db.readDb() %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("db.readDb() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}

          
M go.mod +3 -0
@@ 5,5 5,8 @@ go 1.16
 require (
 	github.com/arran4/golang-ical v0.0.0-20210601225245-48fd351b08e7
 	github.com/cosiner/flag v0.5.2 // indirect
+	github.com/gofrs/flock v0.8.0 // indirect
 	github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
+	github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09 // indirect
+	golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
 )

          
M go.sum +6 -0
@@ 5,13 5,19 @@ github.com/cosiner/flag v0.5.2 h1:dcI3Ex
 github.com/cosiner/flag v0.5.2/go.mod h1:+zDQNSDNnkR7CGUlSrw2d/5S26bL91amx0FVUbnmLrU=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY=
+github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
 github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
+github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09 h1:DXR0VtCesBD2ss3toN9OEeXszpQmW9dc3SvUbUfiBC0=
+github.com/natefinch/atomic v0.0.0-20200526193002-18c0533a5b09/go.mod h1:1rLVY/DWf3U6vSZgH16S7pymfrhK2lcUlXjgGglw/lY=
 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

          
M main.go +57 -95
@@ 3,25 3,20 @@ package main
 import (
 	"fmt"
 	"io"
-	"io/fs"
-	"path/filepath"
 	"regexp"
 	"strings"
 
-	"github.com/arran4/golang-ical"
+	ics "github.com/arran4/golang-ical"
 	"github.com/cosiner/flag"
 
 	"os"
 )
 
+var VERSION string = "dev"
+
 // TODO -C 	 	-- send only to changed attendees
 // TODO -e 	 	-- input is an email
-// TODO -d <path> 	-- database path
 func main() {
-	processFlags(os.Args...)
-}
-
-func processFlags(args ...string) {
 	var send Send
 
 	flag.NewFlagSet(flag.Flag{}).ParseStruct(&send, os.Args...)

          
@@ 31,9 26,12 @@ func processFlags(args ...string) {
 		return
 	}
 
+	cal := NewCal(send.CalPath)
+	dtb := NewDb(send.DbPath)
+
 	if send.Query {
-		dbs := dbStats(send.DbPath)
-		cals := calStats(send.CalPath)
+		dbs := dtb.Stats()
+		cals := cal.Stats()
 		// TODO format output
 		fmt.Printf("%+v\n", dbs)
 		fmt.Printf("%+v\n", cals)

          
@@ 59,62 57,70 @@ func processFlags(args ...string) {
 			// Get sender from email
 			// Find ICS in calendar
 			// Update RSVP in ICS file
-		} else if err := send.sendInvites(in); err != nil {
+		} else if err := sendInvites(send, dtb, in); err != nil {
 			fmt.Errorf("error processing calendar event: %s\n", err)
 		}
 		return
 	}
 
-	// drop through to operate on calendar
-	filepath.WalkDir(send.CalPath, func(p string, d fs.DirEntry, err error) error {
-		if err != nil {
-			return nil
-		}
-		if strings.HasPrefix(p, ".") {
-			return nil
-		}
-		if !d.IsDir() && strings.HasSuffix(d.Name(), ".ics") {
-			in, err := os.Open(p)
-			if err != nil {
-				return err
-			}
-			if err = send.sendInvites(in); err != nil {
-				return err
-			}
-		}
-		return nil
+	cal.ForEachIcs(func(i io.Reader) error {
+		return sendInvites(send, dtb, i)
 	})
+
 	return
 }
 
-/****************************************************************************/
-/* Storage data structures                                                  */
-/****************************************************************************/
-
-// TODO fill in DbStats
-type DbStats struct {
-}
-
-// TODO implement dbStats()
-func dbStats(path string) DbStats {
-	return DbStats{}
-}
-
-// TODO fill in CalStats
-type CalStats struct {
-}
-
-// TODO implement calStats()
-func calStats(path string) CalStats {
-	return CalStats{}
+func sendInvites(s Send, dtb Db, in io.Reader) error {
+	cal, err := ics.ParseCalendar(in)
+	if err != nil {
+		return err
+	}
+	var valid = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
+	for _, e := range cal.Events() {
+		id := e.Id()
+		if len(id) > 37 {
+			id = id[0:17] + "..." + id[len(id)-17:]
+		}
+		if len(e.Attendees()) == 0 {
+			if s.Debug {
+				fmt.Printf("%s has no attendees\n", id)
+			}
+			continue
+		}
+		as := make([]string, 0, len(e.Attendees()))
+		for _, a := range e.Attendees() {
+			email := strings.TrimPrefix(a.Email(), "MAILTO:")
+			if valid.MatchString(email) {
+				as = append(as, email)
+			}
+		}
+		if len(as) == 0 {
+			if s.Debug {
+				fmt.Printf("%s has no valid attendees\n", id)
+			}
+			continue
+		}
+		if s.Debug {
+			fmt.Printf("%s: %s\n", id, strings.Join(as, ", "))
+		}
+		if s.NOP {
+			return nil
+		}
+		if !s.NoEmail {
+			// TODO actually send
+		}
+		err := dtb.UpdateInvites(e.Id(), as)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 /****************************************************************************/
 /* Parse args                                                               */
 /****************************************************************************/
 
-var VERSION string = "dev"
-
 type Send struct {
 	One         string `names:"-f" usage:"Process a single event; filename, or - for stdin"`
 	Version     bool   `names:"-V" usage:"Print version (& exit)"`

          
@@ 187,47 193,3 @@ func (t *Send) Metadata() map[string]fla
 		},
 	}
 }
-
-func (s Send) sendInvites(in io.Reader) error {
-	cal, err := ics.ParseCalendar(in)
-	if err != nil {
-		return err
-	}
-	var valid = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
-	for _, e := range cal.Events() {
-		id := e.Id()
-		if len(id) > 37 {
-			id = id[0:17] + "..." + id[len(id)-17:]
-		}
-		if len(e.Attendees()) == 0 {
-			if s.Debug {
-				fmt.Printf("%s has no attendees\n", id)
-			}
-			continue
-		}
-		as := make([]string, 0, len(e.Attendees()))
-		for _, a := range e.Attendees() {
-			email := strings.TrimPrefix(a.Email(), "MAILTO:")
-			if valid.MatchString(email) {
-				as = append(as, email)
-			}
-		}
-		if len(as) == 0 {
-			if s.Debug {
-				fmt.Printf("%s has no valid attendees\n", id)
-			}
-			continue
-		}
-		if s.Debug {
-			fmt.Printf("%s: %s\n", id, strings.Join(as, ", "))
-		}
-		if s.NOP {
-			return nil
-		}
-		if !s.NoEmail {
-			// TODO actually send
-		}
-		// TODO record in DB
-	}
-	return nil
-}

          
A => main_test.go +85 -0
@@ 0,0 1,85 @@ 
+package main
+
+import (
+	"io"
+	"reflect"
+	"testing"
+
+	"github.com/cosiner/flag"
+)
+
+func Test_main(t *testing.T) {
+	tests := []struct {
+		name string
+	}{
+	// TODO: Add test cases.
+	}
+	for range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			main()
+		})
+	}
+}
+
+func Test_sendInvites(t *testing.T) {
+	type args struct {
+		s   Send
+		dtb Db
+		in  io.Reader
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := sendInvites(tt.args.s, tt.args.dtb, tt.args.in); (err != nil) != tt.wantErr {
+				t.Errorf("sendInvites() %s error = %v, wantErr %v", tt.name, err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestSend_Metadata(t *testing.T) {
+	type fields struct {
+		One         string
+		Version     bool
+		Query       bool
+		NOP         bool
+		NoEmail     bool
+		OnlyChanged bool
+		Debug       bool
+		DbPath      string
+		CalPath     string
+		MailInput   bool
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		want   map[string]flag.Flag
+	}{
+	// TODO: Add test cases.
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t := &Send{
+				One:         tt.fields.One,
+				Version:     tt.fields.Version,
+				Query:       tt.fields.Query,
+				NOP:         tt.fields.NOP,
+				NoEmail:     tt.fields.NoEmail,
+				OnlyChanged: tt.fields.OnlyChanged,
+				Debug:       tt.fields.Debug,
+				DbPath:      tt.fields.DbPath,
+				CalPath:     tt.fields.CalPath,
+				MailInput:   tt.fields.MailInput,
+			}
+			if got := t.Metadata(); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Send.Metadata() %s = %v, want %v", tt.name, got, tt.want)
+			}
+		})
+	}
+}