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)
+ }
+ })
+ }
+}