diff --git a/.gitignore b/.gitignore index 2e6ece2..385bf01 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /gte-generate-recurring /gte /gte-daemon +test.db diff --git a/cmd/cli/command/command.go b/cmd/cli/command/command.go new file mode 100644 index 0000000..10b7f1c --- /dev/null +++ b/cmd/cli/command/command.go @@ -0,0 +1,126 @@ +package command + +import ( + "errors" + "fmt" + + "git.ewintr.nl/gte/internal/configuration" + "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/storage" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/mstore" +) + +var ( + ErrInitCommand = errors.New("could not initialize command") + ErrFailedCommand = errors.New("could not execute command") +) + +type Result struct { + Message string +} + +type Command interface { + Do() (Result, error) +} + +func Parse(args []string, conf *configuration.Configuration) (Command, error) { + if len(args) == 0 { + return NewEmpty() + } + + cmd, _ := args[0], args[1:] + switch cmd { + case "sync": + return NewSync(conf) + case "today": + return NewToday(conf) + default: + return NewEmpty() + } +} + +type Empty struct{} + +func NewEmpty() (*Empty, error) { + return &Empty{}, nil +} + +func (cmd *Empty) Do() (Result, error) { + return Result{ + Message: "did nothing\n", + }, nil +} + +type Sync struct { + syncer *process.Sync +} + +func NewSync(conf *configuration.Configuration) (*Sync, error) { + msgStore := mstore.NewIMAP(conf.IMAP()) + remote := storage.NewRemoteRepository(msgStore) + local, err := storage.NewSqlite(conf.Sqlite()) + if err != nil { + return &Sync{}, fmt.Errorf("%w: %v", ErrInitCommand, err) + } + syncer := process.NewSync(remote, local) + + return &Sync{ + syncer: syncer, + }, nil +} + +func (s *Sync) Do() (Result, error) { + result, err := s.syncer.Process() + if err != nil { + return Result{}, fmt.Errorf("%w: %v", ErrFailedCommand, err) + } + + return Result{ + Message: fmt.Sprintf("synced %d tasks\n", result.Count), + }, nil +} + +type Today struct { + local storage.LocalRepository +} + +func NewToday(conf *configuration.Configuration) (*Today, error) { + local, err := storage.NewSqlite(conf.Sqlite()) + if err != nil { + return &Today{}, fmt.Errorf("%w: %v", ErrInitCommand, err) + } + + return &Today{ + local: local, + }, nil +} + +func (t *Today) Do() (Result, error) { + tasks, err := t.local.FindAllInFolder(task.FOLDER_PLANNED) + if err != nil { + return Result{}, fmt.Errorf("%w: %v", ErrFailedCommand, err) + } + + todayTasks := []*task.Task{} + for _, t := range tasks { + if t.Due == task.Today || task.Today.After(t.Due) { + todayTasks = append(todayTasks, t) + } + } + + if len(todayTasks) == 0 { + return Result{ + Message: "nothing left", + }, nil + } + + var msg string + for _, t := range todayTasks { + msg += fmt.Sprintf("%s - %s\n", t.Project, t.Action) + } + + return Result{ + Message: msg, + }, nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..614ac67 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "os" + + "git.ewintr.nl/go-kit/log" + "git.ewintr.nl/gte/cmd/cli/command" + "git.ewintr.nl/gte/internal/configuration" +) + +func main() { + loglevel := log.LogLevel("error") + if os.Getenv("GTE_LOGLEVEL") != "" { + loglevel = log.LogLevel(os.Getenv("GTE_LOGLEVEL")) + } + logger := log.New(os.Stdout).WithField("cmd", "cli") + logger.SetLogLevel(loglevel) + + configPath := "/home/erik/.config/gte/gte.conf" + if os.Getenv("GTE_CONFIG") != "" { + configPath = os.Getenv("GTE_CONFIG") + } + configFile, err := os.Open(configPath) + if err != nil { + logger.WithErr(err).Error("could not open config file") + os.Exit(1) + } + config := configuration.New(configFile) + + cmd, err := command.Parse(os.Args[1:], config) + if err != nil { + logger.WithErr(err).Error("could not initialize command") + os.Exit(1) + } + result, err := cmd.Do() + if err != nil { + logger.WithErr(err).Error("could perform command") + os.Exit(1) + } + fmt.Printf("%s\n", result.Message) +} diff --git a/cmd/daemon/service.go b/cmd/daemon/service.go index 30a5aa9..eb5fcbb 100644 --- a/cmd/daemon/service.go +++ b/cmd/daemon/service.go @@ -9,6 +9,7 @@ import ( "git.ewintr.nl/go-kit/log" "git.ewintr.nl/gte/internal/configuration" "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" @@ -35,8 +36,8 @@ func main() { msgStore := mstore.NewIMAP(config.IMAP()) mailSend := msend.NewSSLSMTP(config.SMTP()) - repo := task.NewRemoteRepository(msgStore) - disp := task.NewDispatcher(mailSend) + repo := storage.NewRemoteRepository(msgStore) + disp := storage.NewDispatcher(mailSend) inboxProc := process.NewInbox(repo) recurProc := process.NewRecur(repo, disp, *daysAhead) diff --git a/cmd/generate-recurring/main.go b/cmd/generate-recurring/main.go index caf0093..c4a719a 100644 --- a/cmd/generate-recurring/main.go +++ b/cmd/generate-recurring/main.go @@ -7,7 +7,7 @@ import ( "git.ewintr.nl/go-kit/log" "git.ewintr.nl/gte/internal/configuration" "git.ewintr.nl/gte/internal/process" - "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" ) @@ -28,8 +28,8 @@ func main() { msgStore := mstore.NewIMAP(config.IMAP()) mailSend := msend.NewSSLSMTP(config.SMTP()) - taskRepo := task.NewRemoteRepository(msgStore) - taskDisp := task.NewDispatcher(mailSend) + taskRepo := storage.NewRemoteRepository(msgStore) + taskDisp := storage.NewDispatcher(mailSend) recur := process.NewRecur(taskRepo, taskDisp, *daysAhead) result, err := recur.Process() diff --git a/cmd/process-inbox/main.go b/cmd/process-inbox/main.go index 43f7568..4c2cc84 100644 --- a/cmd/process-inbox/main.go +++ b/cmd/process-inbox/main.go @@ -7,7 +7,7 @@ import ( "git.ewintr.nl/go-kit/log" "git.ewintr.nl/gte/internal/configuration" "git.ewintr.nl/gte/internal/process" - "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/pkg/mstore" ) @@ -24,7 +24,7 @@ func main() { } config := configuration.New(configFile) msgStore := mstore.NewIMAP(config.IMAP()) - inboxProcessor := process.NewInbox(task.NewRemoteRepository(msgStore)) + inboxProcessor := process.NewInbox(storage.NewRemoteRepository(msgStore)) result, err := inboxProcessor.Process() if err != nil { diff --git a/go.mod b/go.mod index fd9bcba..a609663 100644 --- a/go.mod +++ b/go.mod @@ -7,5 +7,5 @@ require ( github.com/emersion/go-imap v1.1.0 github.com/emersion/go-message v0.14.1 github.com/google/uuid v1.2.0 - modernc.org/sqlite v1.11.1 // indirect + modernc.org/sqlite v1.11.1 ) diff --git a/internal/configuration/configuration.go b/internal/configuration/configuration.go index 268cc93..f1e7296 100644 --- a/internal/configuration/configuration.go +++ b/internal/configuration/configuration.go @@ -5,7 +5,9 @@ import ( "errors" "io" "strings" + "time" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" ) @@ -28,6 +30,12 @@ type Configuration struct { ToName string ToAddress string + + LocalDBPath string +} + +type LocalConfiguration struct { + MinSyncInterval time.Duration } func New(src io.Reader) *Configuration { @@ -62,6 +70,8 @@ func New(src io.Reader) *Configuration { conf.FromName = value case "from_address": conf.FromAddress = value + case "local_db_path": + conf.LocalDBPath = value } } @@ -85,3 +95,9 @@ func (c *Configuration) SMTP() *msend.SSLSMTPConfig { To: c.ToAddress, } } + +func (c *Configuration) Sqlite() *storage.SqliteConfig { + return &storage.SqliteConfig{ + DBPath: c.LocalDBPath, + } +} diff --git a/internal/configuration/configuration_test.go b/internal/configuration/configuration_test.go index 87c508c..430178c 100644 --- a/internal/configuration/configuration_test.go +++ b/internal/configuration/configuration_test.go @@ -6,6 +6,7 @@ import ( "git.ewintr.nl/go-kit/test" "git.ewintr.nl/gte/internal/configuration" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" ) @@ -67,6 +68,13 @@ func TestNew(t *testing.T) { FromAddress: "from_address", }, }, + { + name: "local", + source: "local_db_path=path", + exp: &configuration.Configuration{ + LocalDBPath: "path", + }, + }, } { t.Run(tc.name, func(t *testing.T) { test.Equals(t, tc.exp, configuration.New(strings.NewReader(tc.source))) @@ -86,6 +94,7 @@ func TestConfigs(t *testing.T) { ToAddress: "to_address", FromName: "from_name", FromAddress: "from_address", + LocalDBPath: "db_path", } t.Run("imap", func(t *testing.T) { @@ -108,4 +117,11 @@ func TestConfigs(t *testing.T) { } test.Equals(t, exp, conf.SMTP()) }) + + t.Run("sqlite", func(t *testing.T) { + exp := &storage.SqliteConfig{ + DBPath: "db_path", + } + test.Equals(t, exp, conf.Sqlite()) + }) } diff --git a/internal/process/inbox.go b/internal/process/inbox.go index 8bb80c3..1c4feee 100644 --- a/internal/process/inbox.go +++ b/internal/process/inbox.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" ) @@ -15,8 +16,9 @@ var ( inboxLock sync.Mutex ) +// Inbox processes all messages in INBOX in a remote repository type Inbox struct { - taskRepo *task.RemoteRepository + taskRepo *storage.RemoteRepository } type InboxResult struct { @@ -24,7 +26,7 @@ type InboxResult struct { Count int `json:"count"` } -func NewInbox(repo *task.RemoteRepository) *Inbox { +func NewInbox(repo *storage.RemoteRepository) *Inbox { return &Inbox{ taskRepo: repo, } diff --git a/internal/process/inbox_test.go b/internal/process/inbox_test.go index e771e07..bbd138b 100644 --- a/internal/process/inbox_test.go +++ b/internal/process/inbox_test.go @@ -5,6 +5,7 @@ import ( "git.ewintr.nl/go-kit/test" "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/mstore" ) @@ -107,7 +108,7 @@ func TestInboxProcess(t *testing.T) { } } - inboxProc := process.NewInbox(task.NewRemoteRepository(mstorer)) + inboxProc := process.NewInbox(storage.NewRemoteRepository(mstorer)) actResult, err := inboxProc.Process() test.OK(t, err) diff --git a/internal/process/recur.go b/internal/process/recur.go index 35171ea..161daa3 100644 --- a/internal/process/recur.go +++ b/internal/process/recur.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" ) @@ -15,9 +16,10 @@ var ( recurLock sync.Mutex ) +// Recur generates new tasks from a recurring task for a given day type Recur struct { - taskRepo *task.RemoteRepository - taskDispatcher *task.Dispatcher + taskRepo *storage.RemoteRepository + taskDispatcher *storage.Dispatcher daysAhead int } @@ -26,7 +28,7 @@ type RecurResult struct { Count int `json:"count"` } -func NewRecur(repo *task.RemoteRepository, disp *task.Dispatcher, daysAhead int) *Recur { +func NewRecur(repo *storage.RemoteRepository, disp *storage.Dispatcher, daysAhead int) *Recur { return &Recur{ taskRepo: repo, taskDispatcher: disp, diff --git a/internal/process/recur_test.go b/internal/process/recur_test.go index 1450f7f..f020c98 100644 --- a/internal/process/recur_test.go +++ b/internal/process/recur_test.go @@ -5,6 +5,7 @@ import ( "git.ewintr.nl/go-kit/test" "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" @@ -54,7 +55,7 @@ func TestRecurProcess(t *testing.T) { } msender := msend.NewMemory() - recurProc := process.NewRecur(task.NewRemoteRepository(mstorer), task.NewDispatcher(msender), 1) + recurProc := process.NewRecur(storage.NewRemoteRepository(mstorer), storage.NewDispatcher(msender), 1) actResult, err := recurProc.Process() test.OK(t, err) test.Equals(t, tc.expCount, actResult.Count) diff --git a/internal/process/sync.go b/internal/process/sync.go new file mode 100644 index 0000000..2dc593f --- /dev/null +++ b/internal/process/sync.go @@ -0,0 +1,60 @@ +package process + +import ( + "errors" + "fmt" + "time" + + "git.ewintr.nl/gte/internal/storage" + "git.ewintr.nl/gte/internal/task" +) + +var ( + ErrSyncProcess = errors.New("could not sync local repository") +) + +// Sync fetches all tasks in regular folders from the remote repository and overwrites what is stored locally +type Sync struct { + remote *storage.RemoteRepository + local storage.LocalRepository +} + +type SyncResult struct { + Duration string `json:"duration"` + Count int `json:"count"` +} + +func NewSync(remote *storage.RemoteRepository, local storage.LocalRepository) *Sync { + return &Sync{ + remote: remote, + local: local, + } +} + +func (s *Sync) Process() (*SyncResult, error) { + start := time.Now() + + tasks := []*task.Task{} + for _, folder := range task.KnownFolders { + if folder == task.FOLDER_INBOX { + continue + } + folderTasks, err := s.remote.FindAll(folder) + if err != nil { + return &SyncResult{}, fmt.Errorf("%w: %v", ErrSyncProcess, err) + } + + for _, t := range folderTasks { + tasks = append(tasks, t) + } + } + + if err := s.local.SetTasks(tasks); err != nil { + return &SyncResult{}, fmt.Errorf("%w: %v", ErrSyncProcess, err) + } + + return &SyncResult{ + Duration: time.Since(start).String(), + Count: len(tasks), + }, nil +} diff --git a/internal/process/sync_test.go b/internal/process/sync_test.go new file mode 100644 index 0000000..6ac2aca --- /dev/null +++ b/internal/process/sync_test.go @@ -0,0 +1,44 @@ +package process_test + +import ( + "testing" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/storage" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/mstore" +) + +func TestSyncProcess(t *testing.T) { + task1 := &task.Task{ + Id: "id1", + Version: 1, + Action: "action1", + Folder: task.FOLDER_NEW, + } + task2 := &task.Task{ + Id: "id2", + Version: 1, + Action: "action2", + Folder: task.FOLDER_UNPLANNED, + } + + mstorer, err := mstore.NewMemory(task.KnownFolders) + test.OK(t, err) + test.OK(t, mstorer.Add(task1.Folder, task1.FormatSubject(), task1.FormatBody())) + test.OK(t, mstorer.Add(task2.Folder, task2.FormatSubject(), task2.FormatBody())) + remote := storage.NewRemoteRepository(mstorer) + local := storage.NewMemory() + + syncer := process.NewSync(remote, local) + actResult, err := syncer.Process() + test.OK(t, err) + test.Equals(t, 2, actResult.Count) + actTasks1, err := local.FindAllInFolder(task.FOLDER_NEW) + test.OK(t, err) + test.Equals(t, []*task.Task{task1}, actTasks1) + actTasks2, err := local.FindAllInFolder(task.FOLDER_UNPLANNED) + test.OK(t, err) + test.Equals(t, []*task.Task{task2}, actTasks2) +} diff --git a/internal/task/dispatch.go b/internal/storage/dispatch.go similarity index 64% rename from internal/task/dispatch.go rename to internal/storage/dispatch.go index 0852347..36bf4d2 100644 --- a/internal/task/dispatch.go +++ b/internal/storage/dispatch.go @@ -1,6 +1,9 @@ -package task +package storage -import "git.ewintr.nl/gte/pkg/msend" +import ( + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/msend" +) type Dispatcher struct { msender msend.MSender @@ -12,7 +15,7 @@ func NewDispatcher(msender msend.MSender) *Dispatcher { } } -func (d *Dispatcher) Dispatch(t *Task) error { +func (d *Dispatcher) Dispatch(t *task.Task) error { return d.msender.Send(&msend.Message{ Subject: t.FormatSubject(), Body: t.FormatBody(), diff --git a/internal/task/dispatch_test.go b/internal/storage/dispatch_test.go similarity index 88% rename from internal/task/dispatch_test.go rename to internal/storage/dispatch_test.go index 7de9d6e..522f46d 100644 --- a/internal/task/dispatch_test.go +++ b/internal/storage/dispatch_test.go @@ -1,17 +1,18 @@ -package task_test +package storage_test import ( "fmt" "testing" "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/msend" ) func TestDispatcherDispatch(t *testing.T) { mem := msend.NewMemory() - disp := task.NewDispatcher(mem) + disp := storage.NewDispatcher(mem) tsk := &task.Task{ Id: "id", Version: 3, diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..90c7244 --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,14 @@ +package storage + +import ( + "time" + + "git.ewintr.nl/gte/internal/task" +) + +type LocalRepository interface { + LatestSync() (time.Time, error) + SetTasks(tasks []*task.Task) error + FindAllInFolder(folder string) ([]*task.Task, error) + FindAllInProject(project string) ([]*task.Task, error) +} diff --git a/internal/storage/memory.go b/internal/storage/memory.go new file mode 100644 index 0000000..111004a --- /dev/null +++ b/internal/storage/memory.go @@ -0,0 +1,58 @@ +package storage + +import ( + "time" + + "git.ewintr.nl/gte/internal/task" +) + +// Memory is an in memory implementation of LocalRepository +type Memory struct { + tasks []*task.Task + latestSync time.Time +} + +func NewMemory() *Memory { + return &Memory{ + tasks: []*task.Task{}, + } +} + +func (m *Memory) LatestSync() (time.Time, error) { + return m.latestSync, nil +} + +func (m *Memory) SetTasks(tasks []*task.Task) error { + nTasks := []*task.Task{} + for _, t := range tasks { + nt := *t + nt.Message = nil + nTasks = append(nTasks, &nt) + } + m.tasks = nTasks + m.latestSync = time.Now() + + return nil +} + +func (m *Memory) FindAllInFolder(folder string) ([]*task.Task, error) { + tasks := []*task.Task{} + for _, t := range m.tasks { + if t.Folder == folder { + tasks = append(tasks, t) + } + } + + return tasks, nil +} + +func (m *Memory) FindAllInProject(project string) ([]*task.Task, error) { + tasks := []*task.Task{} + for _, t := range m.tasks { + if t.Project == project { + tasks = append(tasks, t) + } + } + + return tasks, nil +} diff --git a/internal/storage/memory_test.go b/internal/storage/memory_test.go new file mode 100644 index 0000000..2a53a85 --- /dev/null +++ b/internal/storage/memory_test.go @@ -0,0 +1,78 @@ +package storage_test + +import ( + "testing" + "time" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/storage" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/mstore" +) + +func TestMemory(t *testing.T) { + folder1, folder2 := "folder1", "folder2" + project1, project2 := "project1", "project2" + task1 := &task.Task{ + Folder: folder1, + Project: project1, + Action: "action1", + Message: &mstore.Message{ + Subject: "action1", + }, + } + task2 := &task.Task{ + Folder: folder1, + Project: project2, + Action: "action2", + Message: &mstore.Message{ + Subject: "action2", + }, + } + task3 := &task.Task{ + Folder: folder2, + Project: project1, + Action: "action3", + Message: &mstore.Message{ + Subject: "action3", + }, + } + tasks := []*task.Task{task1, task2, task3} + + t.Run("sync", func(t *testing.T) { + mem := storage.NewMemory() + latest, err := mem.LatestSync() + test.OK(t, err) + test.Assert(t, latest.IsZero(), "lastest was not zero") + + start := time.Now() + test.OK(t, mem.SetTasks(tasks)) + latest, err = mem.LatestSync() + test.OK(t, err) + test.Assert(t, latest.After(start), "latest was not after start") + }) + + t.Run("findallinfolder", func(t *testing.T) { + mem := storage.NewMemory() + test.OK(t, mem.SetTasks(tasks)) + act, err := mem.FindAllInFolder(folder1) + test.OK(t, err) + exp := []*task.Task{task1, task2} + for _, tsk := range exp { + tsk.Message = nil + } + test.Equals(t, exp, act) + }) + + t.Run("findallinproject", func(t *testing.T) { + mem := storage.NewMemory() + test.OK(t, mem.SetTasks(tasks)) + act, err := mem.FindAllInProject(project1) + test.OK(t, err) + exp := []*task.Task{task1, task3} + for _, tsk := range exp { + tsk.Message = nil + } + test.Equals(t, exp, act) + }) +} diff --git a/internal/task/remoterepository.go b/internal/storage/remote.go similarity index 79% rename from internal/task/remoterepository.go rename to internal/storage/remote.go index f26f7a0..61421b8 100644 --- a/internal/task/remoterepository.go +++ b/internal/storage/remote.go @@ -1,10 +1,11 @@ -package task +package storage import ( "errors" "fmt" "strconv" + "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/mstore" ) @@ -24,23 +25,23 @@ func NewRemoteRepository(ms mstore.MStorer) *RemoteRepository { } } -func (rr *RemoteRepository) FindAll(folder string) ([]*Task, error) { +func (rr *RemoteRepository) FindAll(folder string) ([]*task.Task, error) { msgs, err := rr.mstore.Messages(folder) if err != nil { - return []*Task{}, fmt.Errorf("%w: %v", ErrMStoreError, err) + return []*task.Task{}, fmt.Errorf("%w: %v", ErrMStoreError, err) } - tasks := []*Task{} + tasks := []*task.Task{} for _, msg := range msgs { if msg.Valid() { - tasks = append(tasks, NewFromMessage(msg)) + tasks = append(tasks, task.NewFromMessage(msg)) } } return tasks, nil } -func (rr *RemoteRepository) Update(t *Task) error { +func (rr *RemoteRepository) Update(t *task.Task) error { if t == nil { return ErrInvalidTask } @@ -58,7 +59,7 @@ func (rr *RemoteRepository) Update(t *Task) error { return nil } -func (rr *RemoteRepository) Add(t *Task) error { +func (rr *RemoteRepository) Add(t *task.Task) error { if t == nil { return ErrInvalidTask } @@ -80,15 +81,15 @@ func (rr *RemoteRepository) CleanUp() error { } msgsSet := make(map[string][]msgInfo) - for _, folder := range knownFolders { + for _, folder := range task.KnownFolders { msgs, err := rr.mstore.Messages(folder) if err != nil { return fmt.Errorf("%w: %v", ErrMStoreError, err) } for _, msg := range msgs { - id, _ := FieldFromBody(FIELD_ID, msg.Body) - versionStr, _ := FieldFromBody(FIELD_VERSION, msg.Body) + id, _ := task.FieldFromBody(task.FIELD_ID, msg.Body) + versionStr, _ := task.FieldFromBody(task.FIELD_VERSION, msg.Body) version, _ := strconv.Atoi(versionStr) if _, ok := msgsSet[id]; !ok { msgsSet[id] = []msgInfo{} diff --git a/internal/task/remoterepository_test.go b/internal/storage/remote_test.go similarity index 91% rename from internal/task/remoterepository_test.go rename to internal/storage/remote_test.go index 606664d..bb265b6 100644 --- a/internal/task/remoterepository_test.go +++ b/internal/storage/remote_test.go @@ -1,4 +1,4 @@ -package task_test +package storage_test import ( "errors" @@ -6,6 +6,7 @@ import ( "testing" "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/storage" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/mstore" ) @@ -33,7 +34,7 @@ func TestRepoFindAll(t *testing.T) { { name: "unknown folder", folder: "unknown", - expErr: task.ErrMStoreError, + expErr: storage.ErrMStoreError, }, { name: "not empty", @@ -53,7 +54,7 @@ func TestRepoFindAll(t *testing.T) { for _, task := range tc.tasks { test.OK(t, store.Add(task.Folder, task.Subject, "body")) } - repo := task.NewRemoteRepository(store) + repo := storage.NewRemoteRepository(store) actTasks, err := repo.FindAll(tc.folder) test.Equals(t, true, errors.Is(err, tc.expErr)) if err != nil { @@ -86,7 +87,7 @@ func TestRepoUpdate(t *testing.T) { }{ { name: "nil task", - expErr: task.ErrInvalidTask, + expErr: storage.ErrInvalidTask, }, { name: "task without message", @@ -95,7 +96,7 @@ func TestRepoUpdate(t *testing.T) { Folder: folder, Action: action, }, - expErr: task.ErrMStoreError, + expErr: storage.ErrMStoreError, }, { name: "changed task", @@ -115,7 +116,7 @@ func TestRepoUpdate(t *testing.T) { test.OK(t, err) test.OK(t, mem.Add(oldMsg.Folder, oldMsg.Subject, oldMsg.Body)) - repo := task.NewRemoteRepository(mem) + repo := storage.NewRemoteRepository(mem) actErr := repo.Update(tc.task) test.Equals(t, true, errors.Is(actErr, tc.expErr)) @@ -157,7 +158,7 @@ version: %d test.OK(t, mem.Add(folder, subject, body)) } - repo := task.NewRemoteRepository(mem) + repo := storage.NewRemoteRepository(mem) test.OK(t, repo.CleanUp()) expNew := []*mstore.Message{} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..22fc0e5 --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,216 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.ewintr.nl/gte/internal/task" + _ "modernc.org/sqlite" +) + +type sqliteMigration string + +var sqliteMigrations = []sqliteMigration{ + `CREATE TABLE task ("id" TEXT, "version" INTEGER, "folder" TEXT, "action" TEXT, "project" TEXT, "due" TEXT, "recur" TEXT)`, + `CREATE TABLE system ("latest_sync" INTEGER)`, + `INSERT INTO system (latest_sync) VALUES (0)`, +} + +var ( + ErrInvalidConfiguration = errors.New("invalid configuration") + ErrIncompatibleSQLMigration = errors.New("incompatible migration") + ErrNotEnoughSQLMigrations = errors.New("already more migrations than wanted") + ErrSqliteFailure = errors.New("sqlite returned an error") +) + +type SqliteConfig struct { + DBPath string +} + +// Sqlite is an sqlite implementation of LocalRepository +type Sqlite struct { + db *sql.DB +} + +func NewSqlite(conf *SqliteConfig) (*Sqlite, error) { + db, err := sql.Open("sqlite", conf.DBPath) + if err != nil { + return &Sqlite{}, fmt.Errorf("%w: %v", ErrInvalidConfiguration, err) + } + + s := &Sqlite{ + db: db, + } + + if err := s.migrate(sqliteMigrations); err != nil { + return &Sqlite{}, err + } + + return s, nil +} + +func (s *Sqlite) LatestSync() (time.Time, error) { + rows, err := s.db.Query(`SELECT latest_sync FROM system`) + if err != nil { + return time.Time{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + defer rows.Close() + + rows.Next() + var latest int64 + if err := rows.Scan(&latest); err != nil { + return time.Time{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + return time.Unix(latest, 0), nil +} + +func (s *Sqlite) SetTasks(tasks []*task.Task) error { + if _, err := s.db.Exec(`DELETE FROM task`); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + for _, t := range tasks { + var recurStr string + if t.Recur != nil { + recurStr = t.Recur.String() + } + _, err := s.db.Exec(` +INSERT INTO task +(id, version, folder, action, project, due, recur) +VALUES +(?, ?, ?, ?, ?, ?, ?)`, + t.Id, t.Version, t.Folder, t.Action, t.Project, t.Due.String(), recurStr) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + } + + if _, err := s.db.Exec(` +UPDATE system +SET latest_sync = ?`, + time.Now().Unix()); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + return nil +} + +func (s *Sqlite) FindAllInFolder(folder string) ([]*task.Task, error) { + rows, err := s.db.Query(` +SELECT id, version, folder, action, project, due, recur +FROM task +WHERE folder = ?`, folder) + if err != nil { + return []*task.Task{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + return tasksFromRows(rows) +} + +func (s *Sqlite) FindAllInProject(project string) ([]*task.Task, error) { + rows, err := s.db.Query(` +SELECT id, version, folder, action, project, due, recur +FROM task +WHERE project = ?`, project) + if err != nil { + return []*task.Task{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + return tasksFromRows(rows) +} + +func tasksFromRows(rows *sql.Rows) ([]*task.Task, error) { + tasks := []*task.Task{} + + defer rows.Close() + for rows.Next() { + var id, folder, action, project, due, recur string + var version int + if err := rows.Scan(&id, &version, &folder, &action, &project, &due, &recur); err != nil { + return []*task.Task{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + tasks = append(tasks, &task.Task{ + Id: id, + Version: version, + Folder: folder, + Action: action, + Project: project, + Due: task.NewDateFromString(due), + Recur: task.NewRecurrer(recur), + }) + } + + return tasks, nil +} + +func (s *Sqlite) migrate(wanted []sqliteMigration) error { + // admin table + if _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS migration +("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "query" TEXT) +`); err != nil { + return err + } + + // find existing + rows, err := s.db.Query(`SELECT query FROM migration ORDER BY id`) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + existing := []sqliteMigration{} + for rows.Next() { + var query string + if err := rows.Scan(&query); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + existing = append(existing, sqliteMigration(query)) + } + rows.Close() + + // compare + missing, err := compareMigrations(wanted, existing) + if err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + // execute missing + for _, query := range missing { + if _, err := s.db.Exec(string(query)); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + + // register + if _, err := s.db.Exec(` +INSERT INTO migration +(query) VALUES (?) +`, query); err != nil { + return fmt.Errorf("%w: %v", ErrSqliteFailure, err) + } + } + + return nil +} + +func compareMigrations(wanted, existing []sqliteMigration) ([]sqliteMigration, error) { + needed := []sqliteMigration{} + if len(wanted) < len(existing) { + return []sqliteMigration{}, ErrNotEnoughSQLMigrations + } + + for i, want := range wanted { + switch { + case i >= len(existing): + needed = append(needed, want) + case want == existing[i]: + // do nothing + case want != existing[i]: + return []sqliteMigration{}, fmt.Errorf("%w: %v", ErrIncompatibleSQLMigration, want) + } + } + + return needed, nil +} diff --git a/internal/task/task.go b/internal/task/task.go index e801f3f..7520d53 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -37,7 +37,7 @@ const ( ) var ( - knownFolders = []string{ + KnownFolders = []string{ FOLDER_INBOX, FOLDER_NEW, FOLDER_RECURRING, @@ -57,6 +57,9 @@ var ( ) type Task struct { + // Message is the underlying message from which the task was created + // It only has meaning for remote repositories and will be nil in + // local situations. It will be filtered out in LocalRepository.SetTasks() Message *mstore.Message Id string