~ewintr/gte

a2a0e9bb7b9bd04f3575df55638eec367c73d537 — Erik Winter 3 months ago 41a27a4
split off sync merge func
M internal/process/list.go => internal/process/list.go +13 -11
@@ 56,20 56,21 @@ func (l *List) Process() (*ListResult, error) {
		return &ListResult{}, ErrInvalidReqs
	}

	folders := []string{task.FOLDER_NEW, task.FOLDER_PLANNED, task.FOLDER_UNPLANNED}
	if l.reqs.Folder != "" {
		folders = []string{l.reqs.Folder}
	potentialTasks, err := l.local.FindAll()
	if err != nil {
		return &ListResult{}, fmt.Errorf("%w: %v", ErrListProcess, err)
	}

	var potentialTasks []*task.LocalTask
	for _, folder := range folders {
		folderTasks, err := l.local.FindAllInFolder(folder)
		if err != nil {
			return &ListResult{}, fmt.Errorf("%w: %v", ErrListProcess, err)
		}
		for _, ft := range folderTasks {
			potentialTasks = append(potentialTasks, ft)
	// folder
	if l.reqs.Folder != "" {
		var folderTasks []*task.LocalTask
		for _, pt := range potentialTasks {
			if pt.Folder == l.reqs.Folder {
				folderTasks = append(folderTasks, pt)
			}
		}

		potentialTasks = folderTasks
	}

	if l.reqs.Due.IsZero() && l.reqs.Project == "" {


@@ 78,6 79,7 @@ func (l *List) Process() (*ListResult, error) {
		}, nil
	}

	// project
	if l.reqs.Project != "" {
		var projectTasks []*task.LocalTask
		for _, pt := range potentialTasks {

M internal/process/list_test.go => internal/process/list_test.go +15 -6
@@ 2,6 2,7 @@ package process_test

import (
	"errors"
	"sort"
	"testing"

	"git.ewintr.nl/go-kit/test"


@@ 47,9 48,9 @@ func TestListProcess(t *testing.T) {
		Project: "project2",
	}
	allTasks := []*task.Task{task1, task2, task3, task4}
	localTask2 := &task.LocalTask{Task: *task2, LocalId: 2}
	localTask3 := &task.LocalTask{Task: *task3, LocalId: 3}
	localTask4 := &task.LocalTask{Task: *task4, LocalId: 4}
	localTask2 := &task.LocalTask{Task: *task2, LocalUpdate: &task.LocalUpdate{}}
	localTask3 := &task.LocalTask{Task: *task3, LocalUpdate: &task.LocalUpdate{}}
	localTask4 := &task.LocalTask{Task: *task4, LocalUpdate: &task.LocalUpdate{}}
	local := storage.NewMemory()
	test.OK(t, local.SetTasks(allTasks))



@@ 96,10 97,18 @@ func TestListProcess(t *testing.T) {
	} {
		t.Run(tc.name, func(t *testing.T) {
			list := process.NewList(local, tc.reqs)

			act, err := list.Process()
			actRes, err := list.Process()
			test.OK(t, err)
			test.Equals(t, tc.exp, act.Tasks)
			act := actRes.Tasks
			for _, a := range act {
				a.LocalId = 0
			}
			sAct := task.ById(act)
			sExp := task.ById(tc.exp)
			sort.Sort(sAct)
			sort.Sort(sExp)

			test.Equals(t, sExp, sAct)
		})
	}
}

M internal/process/projects.go => internal/process/projects.go +3 -8
@@ 6,7 6,6 @@ import (
	"sort"

	"git.ewintr.nl/gte/internal/storage"
	"git.ewintr.nl/gte/internal/task"
)

var (


@@ 24,13 23,9 @@ func NewProjects(local storage.LocalRepository) *Projects {
}

func (p *Projects) Process() ([]string, error) {
	allTasks := []*task.LocalTask{}
	for _, folder := range []string{task.FOLDER_NEW, task.FOLDER_PLANNED, task.FOLDER_UNPLANNED} {
		folderTasks, err := p.local.FindAllInFolder(folder)
		if err != nil {
			return []string{}, fmt.Errorf("%w: %v", ErrCouldNotFetchProjects, err)
		}
		allTasks = append(allTasks, folderTasks...)
	allTasks, err := p.local.FindAll()
	if err != nil {
		return []string{}, fmt.Errorf("%w: %v", ErrCouldNotFetchProjects, err)
	}

	knownMap := map[string]bool{}

M internal/process/sync_test.go => internal/process/sync_test.go +14 -7
@@ 1,6 1,7 @@
package process_test

import (
	"sort"
	"testing"

	"git.ewintr.nl/go-kit/test"


@@ 24,8 25,8 @@ func TestSyncProcess(t *testing.T) {
		Folder:  task.FOLDER_UNPLANNED,
	}

	localTask1 := &task.LocalTask{Task: *task1, LocalId: 1}
	localTask2 := &task.LocalTask{Task: *task2, LocalId: 2}
	localTask1 := &task.LocalTask{Task: *task1, LocalUpdate: &task.LocalUpdate{}}
	localTask2 := &task.LocalTask{Task: *task2, LocalUpdate: &task.LocalUpdate{}}

	mstorer, err := mstore.NewMemory(task.KnownFolders)
	test.OK(t, err)


@@ 38,10 39,16 @@ func TestSyncProcess(t *testing.T) {
	actResult, err := syncer.Process()
	test.OK(t, err)
	test.Equals(t, 2, actResult.Count)
	actTasks1, err := local.FindAllInFolder(task.FOLDER_NEW)
	actTasks, err := local.FindAll()
	test.OK(t, err)
	test.Equals(t, []*task.LocalTask{localTask1}, actTasks1)
	actTasks2, err := local.FindAllInFolder(task.FOLDER_UNPLANNED)
	test.OK(t, err)
	test.Equals(t, []*task.LocalTask{localTask2}, actTasks2)
	for _, a := range actTasks {
		a.LocalId = 0
		a.Message = nil
	}
	exp := task.ById([]*task.LocalTask{localTask1, localTask2})
	sExp := task.ById(exp)
	sAct := task.ById(actTasks)
	sort.Sort(sAct)
	sort.Sort(sExp)
	test.Equals(t, sExp, sAct)
}

M internal/storage/local.go => internal/storage/local.go +71 -2
@@ 15,13 15,25 @@ var (
type LocalRepository interface {
	LatestSync() (time.Time, error)
	SetTasks(tasks []*task.Task) error
	FindAllInFolder(folder string) ([]*task.LocalTask, error)
	FindAllInProject(project string) ([]*task.LocalTask, error)
	FindAll() ([]*task.LocalTask, error)
	FindById(id string) (*task.LocalTask, error)
	FindByLocalId(id int) (*task.LocalTask, error)
	SetLocalUpdate(tsk *task.LocalTask) error
}

// NextLocalId finds a new local id by incrememting to a variable limit.
//
// When tasks are edited, some get removed because they are done or deleted.
// It is very confusing if existing tasks get renumbered, or if a new one
// immediatly gets the id of an removed one. So it is better to just
// increment. However, local id's also benefit from being short, so we
// don't want to keep incrementing forever.
//
// This function takes a list if id's that are in use and sets the limit
// to the nearest power of ten depening on the current highest id used.
// The new id is an incremented one from that max. However, if the limit
// is reached, it first tries to find "holes" in the current sequence,
// starting from the bottom. If there are no holes, the limit is increased.
func NextLocalId(used []int) int {
	if len(used) == 0 {
		return 1


@@ 57,3 69,60 @@ func NextLocalId(used []int) int {

	return limit
}

// MergeNewTaskSet updates a local set of tasks with a remote one
//
// New set is leading and tasks that are not in there get dismissed. Tasks that
// were created locally and got dispatched  might temporarily dissappear if the
// remote inbox has a delay in processing.
func MergeNewTaskSet(oldTasks []*task.LocalTask, newTasks []*task.Task) []*task.LocalTask {

	// create lookups
	resultMap := map[string]*task.LocalTask{}
	for _, nt := range newTasks {
		resultMap[nt.Id] = &task.LocalTask{
			Task:        *nt,
			LocalId:     0,
			LocalUpdate: &task.LocalUpdate{},
		}
	}
	oldMap := map[string]*task.LocalTask{}
	for _, ot := range oldTasks {
		oldMap[ot.Id] = ot
	}

	// apply local id rules:
	// - keep id's that were present in the old set
	// - find new id's for new tasks
	// - assignment of local id's is non deterministic
	var used []int
	for _, ot := range oldTasks {
		if _, ok := resultMap[ot.Id]; ok {
			resultMap[ot.Id].LocalId = ot.LocalId
			used = append(used, ot.LocalId)
		}
	}
	for id, nt := range resultMap {
		if nt.LocalId == 0 {
			newLocalId := NextLocalId(used)
			resultMap[id].LocalId = newLocalId
			used = append(used, newLocalId)
		}
	}

	// apply local update rules:
	// - only keep local updates if the new task hasn't moved to a new version yet
	for _, ot := range oldTasks {
		if nt, ok := resultMap[ot.Id]; ok {
			if ot.LocalUpdate.ForVersion >= nt.Version {
				resultMap[ot.Id].LocalUpdate = ot.LocalUpdate
			}
		}
	}

	var result []*task.LocalTask
	for _, nt := range resultMap {
		result = append(result, nt)
	}
	return result
}

M internal/storage/local_test.go => internal/storage/local_test.go +104 -0
@@ 1,10 1,12 @@
package storage_test

import (
	"sort"
	"testing"

	"git.ewintr.nl/go-kit/test"
	"git.ewintr.nl/gte/internal/storage"
	"git.ewintr.nl/gte/internal/task"
)

func TestNextLocalId(t *testing.T) {


@@ 72,3 74,105 @@ func TestNextLocalId(t *testing.T) {
		})
	}
}

func TestMergeNewTaskSet(t *testing.T) {
	task1 := &task.Task{Id: "id-1", Version: 1, Action: "action-1"}
	task1v2 := &task.Task{Id: "id-1", Version: 2, Action: "action-1v2"}
	task2 := &task.Task{Id: "id-2", Version: 2, Action: "action-2"}
	emptyUpdate := &task.LocalUpdate{}

	t.Run("local ids are added", func(t *testing.T) {
		act1 := storage.MergeNewTaskSet([]*task.LocalTask{}, []*task.Task{task1})
		test.Assert(t, len(act1) == 1, "length was not 1")
		test.Equals(t, 1, act1[0].LocalId)

		act2 := storage.MergeNewTaskSet(act1, []*task.Task{task1, task2})
		var actIds []int
		for _, t := range act2 {
			actIds = append(actIds, t.LocalId)
		}
		sort.Ints(actIds)
		test.Equals(t, []int{1, 2}, actIds)
	})

	for _, tc := range []struct {
		name     string
		oldTasks []*task.LocalTask
		newTasks []*task.Task
		exp      []*task.LocalTask
	}{
		{
			name:     "add tasks and find local ids",
			oldTasks: []*task.LocalTask{},
			newTasks: []*task.Task{task1, task2},
			exp: []*task.LocalTask{
				{Task: *task1, LocalUpdate: emptyUpdate},
				{Task: *task2, LocalUpdate: emptyUpdate},
			},
		},
		{
			name: "update existing task",
			oldTasks: []*task.LocalTask{
				{Task: *task1, LocalUpdate: emptyUpdate},
				{Task: *task2, LocalId: 2, LocalUpdate: emptyUpdate},
			},
			newTasks: []*task.Task{task1v2, task2},
			exp: []*task.LocalTask{
				{Task: *task1v2, LocalUpdate: emptyUpdate},
				{Task: *task2, LocalUpdate: emptyUpdate},
			},
		},
		{
			name: "remove deleted task",
			oldTasks: []*task.LocalTask{
				{Task: *task1, LocalUpdate: emptyUpdate},
				{Task: *task2, LocalUpdate: emptyUpdate},
			},
			newTasks: []*task.Task{task2},
			exp: []*task.LocalTask{
				{Task: *task2, LocalUpdate: emptyUpdate},
			},
		},
		{
			name: "remove only outdated updates",
			oldTasks: []*task.LocalTask{
				{
					Task: *task1,
					LocalUpdate: &task.LocalUpdate{
						ForVersion: 1,
						Project:    "project-v2",
					},
				},
				{
					Task: *task2,
					LocalUpdate: &task.LocalUpdate{
						ForVersion: 2,
						Project:    "project-v3",
					},
				},
			},
			newTasks: []*task.Task{task1v2, task2},
			exp: []*task.LocalTask{
				{Task: *task1v2, LocalUpdate: emptyUpdate},
				{
					Task: *task2,
					LocalUpdate: &task.LocalUpdate{
						ForVersion: 2,
						Project:    "project-v3",
					},
				},
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			sExp := task.ById(tc.exp)
			sAct := task.ById(storage.MergeNewTaskSet(tc.oldTasks, tc.newTasks))
			for i := range sAct {
				sAct[i].LocalId = 0
			}
			sort.Sort(sExp)
			sort.Sort(sAct)
			test.Equals(t, sExp, sAct)
		})
	}
}

M internal/storage/memory.go => internal/storage/memory.go +17 -68
@@ 6,25 6,15 @@ import (
	"git.ewintr.nl/gte/internal/task"
)

type localData struct {
	LocalId     int
	LocalUpdate *task.LocalUpdate
}

// Memory is an in memory implementation of LocalRepository
//
// It is meant for testing and does not make an attempt to
// keep local state between consecutive calls to SetTasks()
type Memory struct {
	tasks      []*task.Task
	tasks      map[string]*task.LocalTask
	latestSync time.Time
	localData  map[string]localData
}

func NewMemory() *Memory {
	return &Memory{
		tasks:     []*task.Task{},
		localData: map[string]localData{},
		tasks: map[string]*task.LocalTask{},
	}
}



@@ 33,56 23,26 @@ func (m *Memory) LatestSync() (time.Time, error) {
}

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.setLocalId(t.Id)
	var oldTasks []*task.LocalTask
	for _, ot := range m.tasks {
		oldTasks = append(oldTasks, ot)
	}
	m.tasks = nTasks
	m.latestSync = time.Now()

	return nil
}

func (m *Memory) setLocalId(id string) {
	used := []int{}
	for _, ld := range m.localData {
		used = append(used, ld.LocalId)
	}

	next := NextLocalId(used)
	m.localData[id] = localData{
		LocalId: next,
	}
}
	newTasks := MergeNewTaskSet(oldTasks, tasks)

func (m *Memory) FindAllInFolder(folder string) ([]*task.LocalTask, error) {
	tasks := []*task.LocalTask{}
	for _, t := range m.tasks {
		if t.Folder == folder {
			tasks = append(tasks, &task.LocalTask{
				Task:        *t,
				LocalId:     m.localData[t.Id].LocalId,
				LocalUpdate: m.localData[t.Id].LocalUpdate,
			})
		}
	m.tasks = map[string]*task.LocalTask{}
	for _, nt := range newTasks {
		m.tasks[nt.Id] = nt
	}
	m.latestSync = time.Now()

	return tasks, nil
	return nil
}

func (m *Memory) FindAllInProject(project string) ([]*task.LocalTask, error) {
func (m *Memory) FindAll() ([]*task.LocalTask, error) {
	tasks := []*task.LocalTask{}
	for _, t := range m.tasks {
		if t.Project == project {
			tasks = append(tasks, &task.LocalTask{
				Task:        *t,
				LocalId:     m.localData[t.Id].LocalId,
				LocalUpdate: m.localData[t.Id].LocalUpdate,
			})
		}
		tasks = append(tasks, t)
	}

	return tasks, nil


@@ 91,11 51,7 @@ func (m *Memory) FindAllInProject(project string) ([]*task.LocalTask, error) {
func (m *Memory) FindById(id string) (*task.LocalTask, error) {
	for _, t := range m.tasks {
		if t.Id == id {
			return &task.LocalTask{
				Task:        *t,
				LocalId:     m.localData[t.Id].LocalId,
				LocalUpdate: m.localData[t.Id].LocalUpdate,
			}, nil
			return t, nil
		}
	}



@@ 104,12 60,8 @@ func (m *Memory) FindById(id string) (*task.LocalTask, error) {

func (m *Memory) FindByLocalId(localId int) (*task.LocalTask, error) {
	for _, t := range m.tasks {
		if m.localData[t.Id].LocalId == localId {
			return &task.LocalTask{
				Task:        *t,
				LocalId:     localId,
				LocalUpdate: m.localData[t.Id].LocalUpdate,
			}, nil
		if t.LocalId == localId {
			return t, nil
		}
	}



@@ 117,10 69,7 @@ func (m *Memory) FindByLocalId(localId int) (*task.LocalTask, error) {
}

func (m *Memory) SetLocalUpdate(tsk *task.LocalTask) error {
	m.localData[tsk.Id] = localData{
		LocalId:     tsk.LocalId,
		LocalUpdate: tsk.LocalUpdate,
	}
	m.tasks[tsk.Id] = tsk

	return nil
}

M internal/storage/memory_test.go => internal/storage/memory_test.go +20 -24
@@ 1,6 1,7 @@
package storage_test

import (
	"sort"
	"testing"
	"time"



@@ 41,9 42,10 @@ func TestMemory(t *testing.T) {
		},
	}
	tasks := []*task.Task{task1, task2, task3}
	localTask1 := &task.LocalTask{Task: *task1, LocalId: 1}
	localTask2 := &task.LocalTask{Task: *task2, LocalId: 2}
	localTask3 := &task.LocalTask{Task: *task3, LocalId: 3}
	emptyUpdate := &task.LocalUpdate{}
	localTask1 := &task.LocalTask{Task: *task1, LocalUpdate: emptyUpdate}
	localTask2 := &task.LocalTask{Task: *task2, LocalUpdate: emptyUpdate}
	localTask3 := &task.LocalTask{Task: *task3, LocalUpdate: emptyUpdate}

	t.Run("sync", func(t *testing.T) {
		mem := storage.NewMemory()


@@ 58,28 60,20 @@ func TestMemory(t *testing.T) {
		test.Assert(t, latest.After(start), "latest was not after start")
	})

	t.Run("findallinfolder", func(t *testing.T) {
	t.Run("findallin", func(t *testing.T) {
		mem := storage.NewMemory()
		test.OK(t, mem.SetTasks(tasks))
		act, err := mem.FindAllInFolder(folder1)
		act, err := mem.FindAll()
		test.OK(t, err)
		exp := []*task.LocalTask{localTask1, localTask2}
		for _, tsk := range exp {
			tsk.Message = nil
		exp := []*task.LocalTask{localTask1, localTask2, localTask3}
		for _, tsk := range act {
			tsk.LocalId = 0
		}
		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.LocalTask{localTask1, localTask3}
		for _, tsk := range exp {
			tsk.Message = nil
		}
		test.Equals(t, exp, act)
		sExp := task.ById(exp)
		sAct := task.ById(act)
		sort.Sort(sExp)
		sort.Sort(sAct)
		test.Equals(t, sExp, sAct)
	})

	t.Run("findbyid", func(t *testing.T) {


@@ 87,15 81,17 @@ func TestMemory(t *testing.T) {
		test.OK(t, mem.SetTasks(tasks))
		act, err := mem.FindById("id-2")
		test.OK(t, err)
		act.LocalId = 0
		test.Equals(t, localTask2, act)
	})

	t.Run("findbylocalid", func(t *testing.T) {
		mem := storage.NewMemory()
		test.OK(t, mem.SetTasks(tasks))
		act, err := mem.FindByLocalId(2)
		test.OK(t, mem.SetTasks([]*task.Task{task1}))
		act, err := mem.FindByLocalId(1)
		test.OK(t, err)
		test.Equals(t, localTask2, act)
		act.LocalId = 0
		test.Equals(t, localTask1, act)
	})

	t.Run("setlocalupdate", func(t *testing.T) {

M internal/storage/sqlite.go => internal/storage/sqlite.go +23 -113
@@ 19,6 19,11 @@ var sqliteMigrations = []sqliteMigration{
	`CREATE TABLE local_id ("id" TEXT UNIQUE, "local_id" INTEGER UNIQUE)`,
	`ALTER TABLE local_id RENAME TO local_task`,
	`ALTER TABLE local_task ADD COLUMN local_update TEXT`,
	`ALTER TABLE task ADD COLUMN local_id INTEGER`,
	`ALTER TABLE task ADD COLUMN local_update TEXT`,
	`UPDATE task SET local_id = (SELECT local_id FROM local_task WHERE local_task.id=task.id)`,
	`UPDATE task SET local_update = (SELECT local_update FROM local_task WHERE local_task.id=task.id)`,
	`DROP TABLE local_task`,
}

var (


@@ 71,134 76,40 @@ func (s *Sqlite) LatestSync() (time.Time, error) {
}

func (s *Sqlite) SetTasks(tasks []*task.Task) error {
	// set tasks
	if _, err := s.db.Exec(`DELETE FROM task`); err != nil {
		return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	oldTasks, err := s.FindAll()
	if err != nil {
		return err
	}
	newTasks := MergeNewTaskSet(oldTasks, tasks)

	type localTaskInfo struct {
		TaskId      string
		TaskVersion int
		LocalId     int
		LocalUpdate task.LocalUpdate
	if _, err := s.db.Exec(`DELETE FROM task`); err != nil {
		return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}
	localIdMap := map[string]localTaskInfo{}
	for _, t := range tasks {
	for _, t := range newTasks {
		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)
(id, local_id, version, folder, action, project, due, recur, local_update)
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)
		}

		localIdMap[t.Id] = localTaskInfo{
			TaskId:      t.Id,
			TaskVersion: t.Version,
			LocalId:     0,
			LocalUpdate: task.LocalUpdate{},
		}
	}

	// set local_ids and local_updates:
	// 1 - find existing
	rows, err := s.db.Query(`SELECT id, local_id, local_update FROM local_task`)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}
	defer rows.Close()
	for rows.Next() {
		var id string
		var localId int
		var localUpdate task.LocalUpdate
		if err := rows.Scan(&id, &localId, &localUpdate); err != nil {
			return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
		}
		if oldInfo, ok := localIdMap[id]; ok {
			newInfo := localTaskInfo{
				TaskId:      oldInfo.TaskId,
				TaskVersion: oldInfo.TaskVersion,
				LocalId:     localId,
				LocalUpdate: localUpdate,
			}
			localIdMap[id] = newInfo
		}
	}

	// 2 - remove old values
	if _, err := s.db.Exec(`DELETE FROM local_task`); err != nil {
		return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}
(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			t.Id, t.LocalId, t.Version, t.Folder, t.Action, t.Project, t.Due.String(), recurStr, t.LocalUpdate)

	// 3 - figure out new values
	var used []int
	for _, info := range localIdMap {
		if info.LocalId != 0 {
			used = append(used, info.LocalId)
		}
	}
	for id, info := range localIdMap {
		newInfo := info
		// find new local_id when needed
		if info.LocalId == 0 {
			newLocalId := NextLocalId(used)
			used = append(used, newLocalId)
			newInfo.LocalId = newLocalId
		}
		// remove local_update when outdated
		if info.LocalUpdate.ForVersion < info.TaskVersion {
			newInfo.LocalUpdate = task.LocalUpdate{}
		}
		localIdMap[id] = newInfo
	}

	// 4 - store new values
	for id, info := range localIdMap {
		if _, err := s.db.Exec(`
INSERT INTO local_task
(id, local_id, local_update)
VALUES
(?, ?, ?)`, id, info.LocalId, info.LocalUpdate); err != nil {
		if err != nil {
			return fmt.Errorf("%w: %v", ErrSqliteFailure, err)
		}
	}

	// update system
	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.LocalTask, error) {
	rows, err := s.db.Query(`
SELECT task.id, local_task.local_id, version, folder, action, project, due, recur, local_task.local_update
FROM task
LEFT JOIN local_task ON task.id = local_task.id
WHERE folder = ?`, folder)
	if err != nil {
		return []*task.LocalTask{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}

	return tasksFromRows(rows)
}

func (s *Sqlite) FindAllInProject(project string) ([]*task.LocalTask, error) {
func (s *Sqlite) FindAll() ([]*task.LocalTask, error) {
	rows, err := s.db.Query(`
SELECT task.id, local_task.local_id, version, folder, action, project, due, recur, local_task.local_update
FROM task
LEFT JOIN local_task ON task.id = local_task.id
WHERE project = ?`, project)
SELECT id, local_id, version, folder, action, project, due, recur, local_update
FROM task`)
	if err != nil {
		return []*task.LocalTask{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}


@@ 211,9 122,8 @@ func (s *Sqlite) FindById(id string) (*task.LocalTask, error) {
	var localId, version int
	var localUpdate task.LocalUpdate
	row := s.db.QueryRow(`
SELECT local_task.local_id, version, folder, action, project, due, recur, local_task.local_update
SELECT local_id, version, folder, action, project, due, recur, local_update
FROM task
LEFT JOIN local_task ON task.id = local_task.id
WHERE task.id = ?
LIMIT 1`, id)
	if err := row.Scan(&localId, &version, &folder, &action, &project, &due, &recur, &localUpdate); err != nil {


@@ 237,7 147,7 @@ LIMIT 1`, id)

func (s *Sqlite) FindByLocalId(localId int) (*task.LocalTask, error) {
	var id string
	row := s.db.QueryRow(`SELECT id FROM local_task WHERE local_id = ?`, localId)
	row := s.db.QueryRow(`SELECT id FROM task WHERE local_id = ?`, localId)
	if err := row.Scan(&id); err != nil {
		return &task.LocalTask{}, fmt.Errorf("%w: %v", ErrSqliteFailure, err)
	}


@@ 252,7 162,7 @@ func (s *Sqlite) FindByLocalId(localId int) (*task.LocalTask, error) {

func (s *Sqlite) SetLocalUpdate(tsk *task.LocalTask) error {
	if _, err := s.db.Exec(`
UPDATE local_task
UPDATE task
SET local_update = ?
WHERE local_id = ?`, tsk.LocalUpdate, tsk.LocalId); err != nil {
		return fmt.Errorf("%w: %v", ErrSqliteFailure, err)

M internal/task/localtask.go => internal/task/localtask.go +6 -0
@@ 53,6 53,12 @@ func (lt *LocalTask) ApplyUpdate() {
	lt.LocalUpdate = &LocalUpdate{}
}

type ById []*LocalTask

func (lt ById) Len() int           { return len(lt) }
func (lt ById) Swap(i, j int)      { lt[i], lt[j] = lt[j], lt[i] }
func (lt ById) Less(i, j int) bool { return lt[i].Id < lt[j].Id }

type ByDue []*LocalTask

func (lt ByDue) Len() int           { return len(lt) }