local client with sync and today

This commit is contained in:
Erik Winter 2021-06-25 09:14:27 +02:00
parent 4b6167cbd0
commit d82f4b7c0a
24 changed files with 725 additions and 38 deletions

1
.gitignore vendored
View File

@ -2,4 +2,5 @@
/gte-generate-recurring
/gte
/gte-daemon
test.db

126
cmd/cli/command/command.go Normal file
View File

@ -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
}

42
cmd/cli/main.go Normal file
View File

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

View File

@ -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)

View File

@ -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()

View File

@ -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 {

2
go.mod
View File

@ -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
)

View File

@ -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,
}
}

View File

@ -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())
})
}

View File

@ -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,
}

View File

@ -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)

View File

@ -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,

View File

@ -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)

60
internal/process/sync.go Normal file
View File

@ -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
}

View File

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

View File

@ -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(),

View File

@ -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,

14
internal/storage/local.go Normal file
View File

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

View File

@ -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
}

View File

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

View File

@ -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{}

View File

@ -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{}

216
internal/storage/sqlite.go Normal file
View File

@ -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
}

View File

@ -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