/
sync.go
412 lines (377 loc) · 11.3 KB
/
sync.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
package todolist
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"strings"
"syscall"
"golang.org/x/crypto/ssh/terminal"
)
type TodoSync struct {
config *Config
store Store
addedTodos []*Todo
modifiedTodos []*Todo
deletedTodos []*Todo
Checkpoint *Todo
Backlog *TodoList
Remote *TodoList
Local *TodoList
}
func NewTodoSync(cfg *Config, s Store) *TodoSync {
return &TodoSync{config: cfg, store: s, Backlog: &TodoList{}, Remote: &TodoList{}, Local: &TodoList{}}
}
func (s *TodoSync) Sync(verbose bool) error {
if s.config.SyncFilepath == "" {
return errors.New("No sync.filepath defined in .todorc config file")
}
syncFilepath := s.config.SyncFilepath
origSyncFilepath := ""
encryptionPassphrase := ""
//Check if encryption desired. If so, create tmp file for decrypted content
//If no encryptionPassphrase value, assume no encryption to be used
if s.config.SyncEncryptionPassphrase != "" {
//Asterisk indicates user wants to provide passphrase on terminal
if strings.HasPrefix(s.config.SyncEncryptionPassphrase, "*") {
encryptionPassphrase = passphraseInput()
//Else a full passphrase was provided in config file
} else {
encryptionPassphrase = s.config.SyncEncryptionPassphrase
}
tmpfile, err := ioutil.TempFile("", "temp_sync_backlog.json")
if err != nil {
log.Fatal(err)
}
defer os.Remove(tmpfile.Name()) // clean up
origSyncFilepath = syncFilepath
syncFilepath = tmpfile.Name()
//println("origSyncFilepath: ", origSyncFilepath)
//println("syncFilepath: ", syncFilepath)
//decrypt remote file, then write plain text to temp file location
decryptFile(origSyncFilepath, syncFilepath, encryptionPassphrase)
}
store := s.store
var err error
var todos []*Todo
todos, err = store.LoadPending()
if err != nil {
println("Error reading pending todos for sync job")
return err
}
s.Local.Load(todos)
todos, err = store.LoadArchived()
if err != nil {
println("Error reading archived todos for sync job")
return err
}
s.Local.Load(todos)
todos, err = store.LoadBacklog(store.GetBacklogFilepath())
if err != nil {
println("Error reading local backlog todos for sync job")
return err
}
backlogCount := 0
s.Backlog.Load(todos)
if len(s.Backlog.Data) > 0 {
if s.Backlog.Data[0].Status == "Checkpoint" {
s.Checkpoint = s.Backlog.Data[0]
backlogCount = len(todos) - 1
} else {
s.Checkpoint = NewTodo()
s.Checkpoint.Status = "Checkpoint"
s.Checkpoint.IsModified = true
backlogCount = len(todos)
}
} else {
s.Checkpoint = NewTodo()
s.Checkpoint.Status = "Checkpoint"
s.Checkpoint.IsModified = true
}
todos, err = store.LoadBacklog(syncFilepath)
if err != nil {
todos = []*Todo{}
}
todos = s.newSinceLastSync(todos, s.Checkpoint)
s.Remote.Load(todos)
//Synchronize the remote and local data
s.syncRemoteChanges()
//Set new checkpoint to be used in backlog and remote backlog
newCheckpoint := NewTodo()
newCheckpoint.Status = "Checkpoint"
newCheckpoint.ModifiedDate = timeToString(Now)
newCheckpoint.IsModified = true
if len(s.Backlog.Data) > 0 && s.Backlog.Data[0].Status == "Checkpoint" {
s.Backlog.Data = s.Backlog.Data[1:] //remove starting checkpoint before updating remote file
}
s.Backlog.Data = append(s.Backlog.Data, newCheckpoint)
store.AppendBacklog(syncFilepath, s.Backlog.Data)
//Re-load Remote Backlog and remove prior checkpoint
s.RemovePriorCheckpointFromSyncFile(syncFilepath)
//If encrypting, read temp file, re-encrypt and write to orig sync file location
if encryptionPassphrase != "" {
encryptFile(syncFilepath, origSyncFilepath, encryptionPassphrase)
store.DeleteBacklog(syncFilepath) //delete temporary file
}
//Delete existing local backlog file
store.DeleteBacklog(store.GetBacklogFilepath())
//Ensure none of the todos has IsModified == true so none end up in the backlog
for _, todo := range s.Local.Data {
todo.IsModified = false
}
//Re-assign all the Ids based on Uuid sort order so that two repos
//that are synced to the same point will have the same ids for same task
s.Local.ReassignAllIds()
//Add newCheckpoint so it is the only entry in the new backlog file
s.Local.Data = append(s.Local.Data, newCheckpoint)
//Save will write todos into pending, archived and backlog files
store.Save(s.Local.Data)
//Print stats about the sync
//No. of Todos added, modified, deleted
fmt.Println("Sync completed.")
fmt.Println("\tUploaded: ", backlogCount)
fmt.Println("\tAdded: ", len(s.addedTodos))
fmt.Println("\tModified: ", len(s.modifiedTodos))
fmt.Println("\tDeleted: ", len(s.deletedTodos))
//If verbose flag is set, also print the actual todos added, modified and deleted
if verbose {
printer := NewScreenPrinter()
report, ok := s.config.GetReport("default")
if !ok {
fmt.Println("ERROR getting default report from config. Can't print Todos")
os.Exit(1)
}
if len(s.addedTodos) > 0 {
fmt.Println("Added:")
printer.PrintReport(report, s.addedTodos)
}
if len(s.modifiedTodos) > 0 {
fmt.Println("Modified:")
printer.PrintReport(report, s.modifiedTodos)
}
if len(s.deletedTodos) > 0 {
fmt.Println("Deleted:")
printer.PrintReport(report, s.deletedTodos)
}
}
return nil
}
func (s *TodoSync) RemovePriorCheckpointFromSyncFile(syncFilepath string) {
todos, err := s.store.LoadBacklog(syncFilepath)
if err != nil {
todos = []*Todo{}
}
todos = s.consolidateBacklog(todos)
idx := 0
for _, todo := range todos {
if todo.Uuid == s.Checkpoint.Uuid {
//println("Removing prior Uuid: ", s.Checkpoint.Uuid)
if idx < len(todos)-1 {
todos = append(todos[0:idx], todos[idx+1:]...)
} else {
todos = todos[0:idx]
}
} else {
idx++
}
}
//Delete existing local backlog file
s.store.DeleteBacklog(syncFilepath)
//Write new backlog file
s.store.AppendBacklog(syncFilepath, todos)
}
func (s *TodoSync) newSinceLastSync(todos []*Todo, checkpoint *Todo) []*Todo {
ret := []*Todo{}
postCheckpoint := false
if checkpoint.ModifiedDate == "" {
postCheckpoint = true
} else {
hasMatchingCheckpoint := false
for _, todo := range todos {
if todo.Uuid == checkpoint.Uuid {
hasMatchingCheckpoint = true
break
}
}
if !hasMatchingCheckpoint {
postCheckpoint = true
}
}
for _, todo := range todos {
if !postCheckpoint {
if todo.Uuid == checkpoint.Uuid {
postCheckpoint = true
}
continue
}
ret = append(ret, todo)
}
return ret
}
func (s *TodoSync) consolidateBacklog(todos []*Todo) []*Todo {
ret := []*Todo{}
m := map[string]int{} //Uuid to index
for _, todo := range todos {
if _, ok := m[todo.Uuid]; ok {
idx := m[todo.Uuid]
ret = append(ret[0:idx], ret[idx+1:]...) //remove earlier entry
}
ret = append(ret, todo)
m[todo.Uuid] = len(ret) - 1 //record location of entry
}
return ret
}
func (s *TodoSync) syncRemoteChanges() {
for _, remoteTodo := range s.Remote.Data {
if remoteTodo.Status == "Checkpoint" {
continue
}
isMatched := false
for _, localTodo := range s.Local.Data {
if localTodo.Uuid == remoteTodo.Uuid {
isMatched = true
//May not need return value here. LocalTodo is updated in s.Local.Data
localTodo = s.diffTodos(localTodo, remoteTodo)
if localTodo.Status == "Deleted" {
s.deletedTodos = append(s.deletedTodos, localTodo)
} else {
s.modifiedTodos = append(s.modifiedTodos, localTodo)
}
}
}
if !isMatched {
if remoteTodo.Status == "Deleted" {
s.deletedTodos = append(s.deletedTodos, remoteTodo)
} else {
remoteTodo.Id = s.Local.NextId()
s.Local.Data = append(s.Local.Data, remoteTodo)
s.addedTodos = append(s.addedTodos, remoteTodo)
}
}
}
}
func (s *TodoSync) diffTodos(local *Todo, remote *Todo) *Todo {
localTime := getModifiedTime(local)
remoteTime := getModifiedTime(remote)
if remoteTime.After(localTime) {
local.Subject = remote.Subject
local.Priority = remote.Priority
local.ModifiedDate = remote.ModifiedDate
local.Wait = remote.Wait
local.Until = remote.Until
local.Due = remote.Due
local.Completed = remote.Completed
local.CompletedDate = remote.CompletedDate
local.Status = remote.Status
local.Notes = remote.Notes
//Determine if adding or removing projects from local
//and invoke todolist.AddProject or todolist.RemoveProject, which
//will ensure the ordinals are updated.
toRemove := inSliceOneNotSliceTwo(local.Projects, remote.Projects)
toAdd := inSliceOneNotSliceTwo(remote.Projects, local.Projects)
for _, p := range toRemove {
s.Local.RemoveProject(p, local)
}
for _, p := range toAdd {
s.Local.AddProject(p, local)
}
toRemove = inSliceOneNotSliceTwo(local.Contexts, remote.Contexts)
toAdd = inSliceOneNotSliceTwo(remote.Contexts, local.Contexts)
for _, c := range toRemove {
s.Local.RemoveContext(c, local)
}
for _, c := range toAdd {
s.Local.AddContext(c, local)
}
local.Ordinals = remote.Ordinals //Do this and above add/remove or just copy remote?
}
//If remoteTime is older than localTime, but still newer than the last sync time
//then what? Assume local with more recent timestamp is most up to date
//on all elements. Not likely to be true, but simple and reasonbly easy rule
//to follow that should do the right thing most of the time.
return local
}
/**
TODO - Expand the following logic
Exec any external script to pull from remote site to local "remote" file
Do the sync
Exec any external script to push local "remote" file to remote location
*/
func (s *TodoSync) Run(script string) bool {
c := exec.Command(script)
if err := c.Run(); err != nil {
fmt.Println("Error: ", err)
return false
}
return true
}
func createHash(key string) string {
hasher := md5.New()
hasher.Write([]byte(key))
return hex.EncodeToString(hasher.Sum(nil))
}
func encrypt(data []byte, passphrase string) []byte {
block, _ := aes.NewCipher([]byte(createHash(passphrase)))
gcm, err := cipher.NewGCM(block)
if err != nil {
panic(err.Error())
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
panic(err.Error())
}
ciphertext := gcm.Seal(nonce, nonce, data, nil)
return ciphertext
}
func decrypt(data []byte, passphrase string) []byte {
key := []byte(createHash(passphrase))
block, err := aes.NewCipher(key)
if err != nil {
panic(err.Error())
}
gcm, err := cipher.NewGCM(block)
if err != nil {
panic(err.Error())
}
nonceSize := gcm.NonceSize()
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
panic(err.Error())
}
return plaintext
}
func encryptFile(srcFilename string, dstFilename string, passphrase string) bool {
data, _ := ioutil.ReadFile(srcFilename)
f, _ := os.Create(dstFilename)
defer f.Close()
_, err := f.Write(encrypt(data, passphrase))
return err != nil
}
func decryptFile(srcFilename string, dstFilename string, passphrase string) bool {
data, _ := ioutil.ReadFile(srcFilename)
if len(data) == 0 {
return true
}
decrypted := decrypt(data, passphrase)
err := ioutil.WriteFile(dstFilename, decrypted, os.FileMode(os.O_RDWR))
return err != nil
}
func passphraseInput() string {
fmt.Print("Enter Password: ")
bytePassword, _ := terminal.ReadPassword(int(syscall.Stdin))
//if err == nil {
// fmt.Println("\nPassword typed: " + string(bytePassword))
//}
password := string(bytePassword)
fmt.Println("")
return strings.TrimSpace(password)
}