diff --git a/Gopkg.lock b/Gopkg.lock index 55733507..9556456c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -57,6 +57,14 @@ revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" version = "v0.0.3" +[[projects]] + digest = "1:bc03901fc8f0965ccba8bc453eae21a9b04f95999eab664c7de6dc7290f4e8f4" + name = "github.com/mattn/go-sqlite3" + packages = ["."] + pruneopts = "" + revision = "25ecb14adfc7543176f7d85291ec7dba82c6f7e4" + version = "v1.9.0" + [[projects]] digest = "1:7365acd48986e205ccb8652cc746f09c8b7876030d53710ea6ef7d0bd0dcd7ca" name = "github.com/pkg/errors" @@ -112,6 +120,7 @@ "github.com/dnote/actions", "github.com/fatih/color", "github.com/google/go-github/github", + "github.com/mattn/go-sqlite3", "github.com/pkg/errors", "github.com/satori/go.uuid", "github.com/spf13/cobra", diff --git a/Gopkg.toml b/Gopkg.toml index ce0c4ad6..abd248c0 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -44,3 +44,7 @@ [[constraint]] name = "github.com/dnote/actions" version = "0.1.0" + +[[constraint]] + name = "github.com/mattn/go-sqlite3" + version = "1.9.0" diff --git a/cmd/add/add.go b/cmd/add/add.go index 13433828..25f0bc93 100644 --- a/cmd/add/add.go +++ b/cmd/add/add.go @@ -1,12 +1,14 @@ package add import ( + "database/sql" "fmt" "time" "github.com/dnote/cli/core" "github.com/dnote/cli/infra" "github.com/dnote/cli/log" + "github.com/dnote/cli/utils" "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -28,6 +30,7 @@ func preRun(cmd *cobra.Command, args []string) error { return nil } +// NewCmd returns a new add command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "add ", @@ -61,8 +64,7 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } ts := time.Now().Unix() - note := core.NewNote(content, ts) - err := writeNote(ctx, bookName, note, ts) + err := writeNote(ctx, bookName, content, ts) if err != nil { return errors.Wrap(err, "Failed to write note") } @@ -80,38 +82,45 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } } -func writeNote(ctx infra.DnoteCtx, bookName string, note infra.Note, ts int64) error { - dnote, err := core.GetDnote(ctx) +func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) error { + tx, err := ctx.DB.Begin() if err != nil { - return errors.Wrap(err, "Failed to get dnote") + return errors.Wrap(err, "beginning a transaction") } - var book infra.Book - - book, ok := dnote[bookName] - if ok { - notes := append(dnote[bookName].Notes, note) - dnote[bookName] = core.GetUpdatedBook(dnote[bookName], notes) - } else { - book = core.NewBook(bookName) - book.Notes = []infra.Note{note} - dnote[bookName] = book - - err = core.LogActionAddBook(ctx, bookName) + var bookUUID string + err = tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID) + if err == sql.ErrNoRows { + bookUUID = utils.GenerateUUID() + _, err = tx.Exec("INSERT INTO books (uuid, label) VALUES (?, ?)", bookUUID, bookLabel) if err != nil { - return errors.Wrap(err, "Failed to log action") + tx.Rollback() + return errors.Wrap(err, "creating the book") } + + err = core.LogActionAddBook(tx, bookLabel) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "logging action") + } + } else if err != nil { + return errors.Wrap(err, "finding the book") } - err = core.LogActionAddNote(ctx, note.UUID, book.Name, note.Content, ts) + noteUUID := utils.GenerateUUID() + _, err = tx.Exec(`INSERT INTO notes (uuid, book_uuid, content, added_on, public) + VALUES (?, ?, ?, ?, ?);`, noteUUID, bookUUID, content, ts, false) if err != nil { - return errors.Wrap(err, "Failed to log action") + tx.Rollback() + return errors.Wrap(err, "creating the note") + } + err = core.LogActionAddNote(tx, noteUUID, bookLabel, content, ts) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "logging action") } - err = core.WriteDnote(ctx, dnote) - if err != nil { - return errors.Wrap(err, "Failed to write to dnote file") - } + tx.Commit() return nil } diff --git a/cmd/cat/cat.go b/cmd/cat/cat.go index 3d5a7b97..0d2b0cb5 100644 --- a/cmd/cat/cat.go +++ b/cmd/cat/cat.go @@ -1,8 +1,8 @@ package cat import ( + "database/sql" "fmt" - "strconv" "time" "github.com/dnote/cli/core" @@ -30,6 +30,7 @@ func preRun(cmd *cobra.Command, args []string) error { return nil } +// NewCmd returns a new cat command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "cat ", @@ -44,30 +45,48 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { return cmd } +type noteInfo struct { + BookLabel string + UUID string + Content string + AddedOn int64 + EditedOn int64 +} + func NewRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { - dnote, err := core.GetDnote(ctx) - if err != nil { - return errors.Wrap(err, "reading dnote") + db := ctx.DB + bookLabel := args[0] + noteID := args[1] + + var bookUUID string + err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&bookUUID) + if err == sql.ErrNoRows { + return errors.Errorf("book '%s' not found", bookLabel) + } else if err != nil { + return errors.Wrap(err, "querying the book") } - bookName := args[0] - noteIdx, err := strconv.Atoi(args[1]) - if err != nil { - return errors.Wrapf(err, "parsing note index '%+v'", args[1]) + var info noteInfo + err = db.QueryRow(`SELECT books.label, notes.uuid, notes.content, notes.added_on, notes.edited_on + FROM notes + INNER JOIN books ON books.uuid = notes.book_uuid + WHERE notes.id = ? AND books.uuid = ?`, noteID, bookUUID). + Scan(&info.BookLabel, &info.UUID, &info.Content, &info.AddedOn, &info.EditedOn) + if err == sql.ErrNoRows { + return errors.Errorf("note %s not found in the book '%s'", noteID, bookLabel) + } else if err != nil { + return errors.Wrap(err, "querying the note") } - book := dnote[bookName] - note := book.Notes[noteIdx] - - log.Infof("book name: %s\n", bookName) - log.Infof("note uuid: %s\n", note.UUID) - log.Infof("created at: %s\n", time.Unix(note.AddedOn, 0).Format("Jan 2, 2006 3:04pm (MST)")) - if note.EditedOn != 0 { - log.Infof("updated at: %s\n", time.Unix(note.EditedOn, 0).Format("Jan 2, 2006 3:04pm (MST)")) + log.Infof("book name: %s\n", info.BookLabel) + log.Infof("note uuid: %s\n", info.UUID) + log.Infof("created at: %s\n", time.Unix(info.AddedOn, 0).Format("Jan 2, 2006 3:04pm (MST)")) + if info.EditedOn != 0 { + log.Infof("updated at: %s\n", time.Unix(info.EditedOn, 0).Format("Jan 2, 2006 3:04pm (MST)")) } fmt.Printf("\n------------------------content------------------------\n") - fmt.Printf("%s", note.Content) + fmt.Printf("%s", info.Content) fmt.Printf("\n-------------------------------------------------------\n") return nil diff --git a/cmd/edit/edit.go b/cmd/edit/edit.go index a8d49370..e81099a5 100644 --- a/cmd/edit/edit.go +++ b/cmd/edit/edit.go @@ -1,8 +1,8 @@ package edit import ( + "database/sql" "io/ioutil" - "strconv" "time" "github.com/dnote/cli/core" @@ -21,6 +21,7 @@ var example = ` * Skip the prompt by providing new content directly dnote edit js 3 -c "new content"` +// NewCmd returns a new edit command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "edit", @@ -47,62 +48,64 @@ func preRun(cmd *cobra.Command, args []string) error { func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { - dnote, err := core.GetDnote(ctx) + db := ctx.DB + bookLabel := args[0] + noteID := args[1] + + bookUUID, err := core.GetBookUUID(ctx, bookLabel) if err != nil { - return errors.Wrap(err, "Failed to read dnote") + return errors.Wrap(err, "finding book uuid") } - targetBookName := args[0] - targetIdx, err := strconv.Atoi(args[1]) - if err != nil { - return errors.Wrapf(err, "Failed to parse the given index %+v", args[1]) + var noteUUID, oldContent string + err = db.QueryRow("SELECT uuid, content FROM notes WHERE id = ? AND book_uuid = ?", noteID, bookUUID).Scan(¬eUUID, &oldContent) + if err == sql.ErrNoRows { + return errors.Errorf("note %s not found in the book '%s'", noteID, bookLabel) + } else if err != nil { + return errors.Wrap(err, "querying the book") } - targetBook, exists := dnote[targetBookName] - if !exists { - return errors.Errorf("Book %s does not exist", targetBookName) - } - if targetIdx > len(targetBook.Notes)-1 { - return errors.Errorf("Book %s does not have note with index %d", targetBookName, targetIdx) - } - targetNote := targetBook.Notes[targetIdx] - if newContent == "" { fpath := core.GetDnoteTmpContentPath(ctx) - e := ioutil.WriteFile(fpath, []byte(targetNote.Content), 0644) + e := ioutil.WriteFile(fpath, []byte(oldContent), 0644) if e != nil { - return errors.Wrap(e, "Failed to prepare editor content") + return errors.Wrap(e, "preparing tmp content file") } e = core.GetEditorInput(ctx, fpath, &newContent) if e != nil { - return errors.Wrap(err, "Failed to get editor input") + return errors.Wrap(err, "getting editor input") } - } - if targetNote.Content == newContent { + if oldContent == newContent { return errors.New("Nothing changed") } ts := time.Now().Unix() + newContent = core.SanitizeContent(newContent) - targetNote.Content = core.SanitizeContent(newContent) - targetNote.EditedOn = ts - targetBook.Notes[targetIdx] = targetNote - dnote[targetBookName] = targetBook - - err = core.LogActionEditNote(ctx, targetNote.UUID, targetBook.Name, targetNote.Content, ts) + tx, err := db.Begin() if err != nil { - return errors.Wrap(err, "Failed to log action") + return errors.Wrap(err, "beginning a transaction") + } + _, err = tx.Exec(`UPDATE notes + SET content = ?, edited_on = ? + WHERE id = ? AND book_uuid = ?`, newContent, ts, noteID, bookUUID) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "updating the note") } - err = core.WriteDnote(ctx, dnote) + err = core.LogActionEditNote(tx, noteUUID, bookLabel, newContent, ts) if err != nil { - return errors.Wrap(err, "Failed to write dnote") + tx.Rollback() + return errors.Wrap(err, "logging an action") } + tx.Commit() + log.Printf("new content: %s\n", newContent) log.Success("edited the note\n") diff --git a/cmd/login/login.go b/cmd/login/login.go index a5138cc1..40f806a3 100644 --- a/cmd/login/login.go +++ b/cmd/login/login.go @@ -13,6 +13,7 @@ import ( var example = ` dnote login` +// NewCmd returns a new login command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "login", diff --git a/cmd/ls/ls.go b/cmd/ls/ls.go index 65a9f95b..930f0d4f 100644 --- a/cmd/ls/ls.go +++ b/cmd/ls/ls.go @@ -1,8 +1,8 @@ package ls import ( + "database/sql" "fmt" - "sort" "strings" "github.com/dnote/cli/core" @@ -33,6 +33,7 @@ func preRun(cmd *cobra.Command, args []string) error { return nil } +// NewCmd returns a new ls command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "ls ", @@ -49,22 +50,17 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { func NewRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { - dnote, err := core.GetDnote(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read dnote") - } - if len(args) == 0 { - if err := printBooks(dnote); err != nil { - return errors.Wrap(err, "Failed to print books") + if err := printBooks(ctx); err != nil { + return errors.Wrap(err, "viewing books") } return nil } bookName := args[0] - if err := printNotes(dnote, bookName); err != nil { - return errors.Wrapf(err, "Failed to print notes for the book %s", bookName) + if err := printNotes(ctx, bookName); err != nil { + return errors.Wrapf(err, "viewing book '%s'", bookName) } return nil @@ -73,18 +69,14 @@ func NewRun(ctx infra.DnoteCtx) core.RunEFunc { // bookInfo is an information about the book to be printed on screen type bookInfo struct { - BookName string + BookLabel string NoteCount int } -func getBookInfos(dnote infra.Dnote) []bookInfo { - var ret []bookInfo - - for bookName, book := range dnote { - ret = append(ret, bookInfo{BookName: bookName, NoteCount: len(book.Notes)}) - } - - return ret +// noteInfo is an information about the note to be printed on screen +type noteInfo struct { + ID int + Content string } // getNewlineIdx returns the index of newline character in a string @@ -114,30 +106,71 @@ func formatContent(noteContent string) (string, bool) { return strings.Trim(noteContent, " "), false } -func printBooks(dnote infra.Dnote) error { - infos := getBookInfos(dnote) +func printBooks(ctx infra.DnoteCtx) error { + db := ctx.DB - // Show books with more notes first - sort.SliceStable(infos, func(i, j int) bool { - return infos[i].NoteCount > infos[j].NoteCount - }) + rows, err := db.Query(`SELECT books.label, count(notes.uuid) note_count + FROM books + INNER JOIN notes ON notes.book_uuid = books.uuid + GROUP BY books.uuid + ORDER BY books.label ASC;`) + if err != nil { + return errors.Wrap(err, "querying books") + } + defer rows.Close() + + infos := []bookInfo{} + for rows.Next() { + var info bookInfo + err = rows.Scan(&info.BookLabel, &info.NoteCount) + if err != nil { + return errors.Wrap(err, "scanning a row") + } + + infos = append(infos, info) + } for _, info := range infos { - log.Printf("%s %s\n", info.BookName, log.SprintfYellow("(%d)", info.NoteCount)) + log.Printf("%s %s\n", info.BookLabel, log.SprintfYellow("(%d)", info.NoteCount)) } return nil } -func printNotes(dnote infra.Dnote, bookName string) error { +func printNotes(ctx infra.DnoteCtx, bookName string) error { + db := ctx.DB + + var bookUUID string + err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", bookName).Scan(&bookUUID) + if err == sql.ErrNoRows { + return errors.New("book not found") + } else if err != nil { + return errors.Wrap(err, "querying the book") + } + + rows, err := db.Query(`SELECT id, content FROM notes WHERE book_uuid = ? ORDER BY added_on ASC;`, bookUUID) + if err != nil { + return errors.Wrap(err, "querying notes") + } + defer rows.Close() + + infos := []noteInfo{} + for rows.Next() { + var info noteInfo + err = rows.Scan(&info.ID, &info.Content) + if err != nil { + return errors.Wrap(err, "scanning a row") + } + + infos = append(infos, info) + } + log.Infof("on book %s\n", bookName) - book := dnote[bookName] + for _, info := range infos { + content, isExcerpt := formatContent(info.Content) - for i, note := range book.Notes { - content, isExcerpt := formatContent(note.Content) - - index := log.SprintfYellow("(%d)", i) + index := log.SprintfYellow("(%d)", info.ID) if isExcerpt { content = fmt.Sprintf("%s %s", content, log.SprintfYellow("[---More---]")) } diff --git a/cmd/remove/remove.go b/cmd/remove/remove.go index b02722c9..dd349e70 100644 --- a/cmd/remove/remove.go +++ b/cmd/remove/remove.go @@ -1,8 +1,8 @@ package remove import ( + "database/sql" "fmt" - "strconv" "github.com/dnote/cli/core" "github.com/dnote/cli/infra" @@ -39,111 +39,109 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { if targetBookName != "" { - err := book(ctx, targetBookName) - if err != nil { - return errors.Wrap(err, "Failed to delete the book") - } - } else { - if len(args) < 2 { - return errors.New("Missing argument") + if err := removeBook(ctx, targetBookName); err != nil { + return errors.Wrap(err, "removing the book") } - targetBook := args[0] - noteIndex, err := strconv.Atoi(args[1]) - if err != nil { - return err - } + return nil + } - err = note(ctx, noteIndex, targetBook) - if err != nil { - return errors.Wrap(err, "Failed to delete the note") - } + if len(args) < 2 { + return errors.New("Missing argument") + } + + targetBook := args[0] + noteID := args[1] + + if err := removeNote(ctx, noteID, targetBook); err != nil { + return errors.Wrap(err, "removing the note") } return nil } } -// note deletes the note in a certain index. -func note(ctx infra.DnoteCtx, index int, bookName string) error { - dnote, err := core.GetDnote(ctx) +func removeNote(ctx infra.DnoteCtx, noteID, bookLabel string) error { + db := ctx.DB + + bookUUID, err := core.GetBookUUID(ctx, bookLabel) if err != nil { - return errors.Wrap(err, "Failed to get dnote") + return errors.Wrap(err, "finding book uuid") } - book, exists := dnote[bookName] - if !exists { - return errors.Errorf("Book with the name '%s' does not exist", bookName) - } - notes := book.Notes - - if len(notes)-1 < index { - fmt.Println("Error : The note with that index is not found.") - return nil + var noteUUID, noteContent string + err = db.QueryRow("SELECT uuid, content FROM notes WHERE id = ? AND book_uuid = ?", noteID, bookUUID).Scan(¬eUUID, ¬eContent) + if err == sql.ErrNoRows { + return errors.Errorf("note %s not found in the book '%s'", noteID, bookLabel) + } else if err != nil { + return errors.Wrap(err, "querying the book") } - content := notes[index].Content - log.Printf("content: \"%s\"\n", content) + // todo: multiline + log.Printf("content: \"%s\"\n", noteContent) ok, err := utils.AskConfirmation("remove this note?", false) if err != nil { - return errors.Wrap(err, "Failed to get confirmation") + return errors.Wrap(err, "getting confirmation") } if !ok { log.Warnf("aborted by user\n") return nil } - note := notes[index] - dnote[bookName] = core.GetUpdatedBook(dnote[bookName], append(notes[:index], notes[index+1:]...)) - - err = core.LogActionRemoveNote(ctx, note.UUID, book.Name) + tx, err := db.Begin() if err != nil { - return errors.Wrap(err, "Failed to log action") + return errors.Wrap(err, "beginning a transaction") } - err = core.WriteDnote(ctx, dnote) - if err != nil { - return errors.Wrap(err, "Failed to write dnote") + if _, err = tx.Exec("DELETE FROM notes WHERE uuid = ? AND book_uuid = ?", noteUUID, bookUUID); err != nil { + return errors.Wrap(err, "removing the note") } + if err = core.LogActionRemoveNote(tx, noteUUID, bookLabel); err != nil { + return errors.Wrap(err, "logging the remove_note action") + } + tx.Commit() + + log.Successf("removed from %s\n", bookLabel) - log.Successf("removed from %s\n", bookName) return nil } -// book deletes a book with the given name -func book(ctx infra.DnoteCtx, bookName string) error { - ok, err := utils.AskConfirmation(fmt.Sprintf("delete book '%s' and all its notes?", bookName), false) +func removeBook(ctx infra.DnoteCtx, bookLabel string) error { + db := ctx.DB + + bookUUID, err := core.GetBookUUID(ctx, bookLabel) if err != nil { - return err + return errors.Wrap(err, "finding book uuid") + } + + ok, err := utils.AskConfirmation(fmt.Sprintf("delete book '%s' and all its notes?", bookLabel), false) + if err != nil { + return errors.Wrap(err, "getting confirmation") } if !ok { log.Warnf("aborted by user\n") return nil } - dnote, err := core.GetDnote(ctx) + tx, err := db.Begin() if err != nil { - return err + return errors.Wrap(err, "beginning a transaction") } - for n, book := range dnote { - if n == bookName { - delete(dnote, n) - - err = core.LogActionRemoveBook(ctx, book.Name) - if err != nil { - return errors.Wrap(err, "Failed to log action") - } - err := core.WriteDnote(ctx, dnote) - if err != nil { - return err - } - - log.Success("removed book\n") - return nil - } + if _, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID); err != nil { + return errors.Wrap(err, "removing notes in the book") + } + if _, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID); err != nil { + return errors.Wrap(err, "removing the book") + } + if err = core.LogActionRemoveBook(tx, bookLabel); err != nil { + return errors.Wrap(err, "loging the remove_book action") } - return errors.Errorf("Book '%s' was not found", bookName) + tx.Commit() + + log.Success("removed book\n") + + return nil } diff --git a/cmd/root/root.go b/cmd/root/root.go index 4928dda0..49c50570 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -27,18 +27,21 @@ func Execute() error { // Prepare initializes necessary files func Prepare(ctx infra.DnoteCtx) error { - err := core.MigrateToDnoteDir(ctx) - if err != nil { - return errors.Wrap(err, "initializing dnote dir") + if err := core.InitFiles(ctx); err != nil { + return errors.Wrap(err, "initializing files") } - err = core.InitFiles(ctx) - if err != nil { - return errors.Wrap(err, "initiating files") + if err := infra.InitDB(ctx); err != nil { + return errors.Wrap(err, "initializing database") + } + if err := infra.InitSystem(ctx); err != nil { + return errors.Wrap(err, "initializing system data") } - err = migrate.Migrate(ctx) - if err != nil { + if err := migrate.Legacy(ctx); err != nil { + return errors.Wrap(err, "running legacy migration") + } + if err := migrate.Run(ctx); err != nil { return errors.Wrap(err, "running migration") } diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go index 5b6ef422..209d66d4 100644 --- a/cmd/sync/sync.go +++ b/cmd/sync/sync.go @@ -3,6 +3,7 @@ package sync import ( "bytes" "compress/gzip" + "database/sql" "encoding/json" "fmt" "io" @@ -20,6 +21,7 @@ import ( var example = ` dnote sync` +// NewCmd returns a new sync command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "sync", @@ -44,38 +46,42 @@ type syncPayload struct { func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { + db := ctx.DB + config, err := core.ReadConfig(ctx) if err != nil { - return errors.Wrap(err, "Failed to read the config") + return errors.Wrap(err, "reading the config") } - timestamp, err := core.ReadTimestamp(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read the timestamp") - } - actions, err := core.ReadActionLog(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read the action log") - } - if config.APIKey == "" { log.Error("login required. please run `dnote login`\n") return nil } - payload, err := getPayload(actions, timestamp) + var bookmark int + err = db.QueryRow("SELECT value FROM system WHERE key = ?", "bookmark").Scan(&bookmark) if err != nil { - return errors.Wrap(err, "Failed to get dnote payload") + return errors.Wrap(err, "getting bookmark") + } + + actions, err := getLocalActions(db) + if err != nil { + return errors.Wrap(err, "getting local actions") + } + + payload, err := newPayload(actions, bookmark) + if err != nil { + return errors.Wrap(err, "getting the request payload") } log.Infof("writing changes (total %d).", len(actions)) resp, err := postActions(ctx, config.APIKey, payload) if err != nil { - return errors.Wrap(err, "Failed to post to the server ") + return errors.Wrap(err, "posting to the server") } body, err := ioutil.ReadAll(resp.Body) if err != nil { - return errors.Wrap(err, "Failed to read failed response body") + return errors.Wrap(err, "reading the response body") } if resp.StatusCode != http.StatusOK { @@ -88,34 +94,36 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { fmt.Println(" done.") var respData responseData - err = json.Unmarshal(body, &respData) + if err = json.Unmarshal(body, &respData); err != nil { + return errors.Wrap(err, "unmarshalling the payload") + } + + // First, remove our actions because server has successfully ingested them + if _, err = db.Exec("DELETE FROM actions"); err != nil { + return errors.Wrap(err, "deleting actions") + } + + tx, err := db.Begin() if err != nil { - return errors.Wrap(err, "Failed to unmarshal payload") + return errors.Wrap(err, "beginning a transaction") } log.Infof("resolving delta (total %d).", len(respData.Actions)) - err = core.ReduceAll(ctx, respData.Actions) - if err != nil { - return errors.Wrap(err, "Failed to reduce returned actions") + if err := core.ReduceAll(ctx, tx, respData.Actions); err != nil { + tx.Rollback() + return errors.Wrap(err, "reducing returned actions") } + + if _, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", respData.Bookmark, "bookmark"); err != nil { + tx.Rollback() + return errors.Wrap(err, "updating the bookmark") + } + fmt.Println(" done.") - // Update bookmark - ts, err := core.ReadTimestamp(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read the timestamp") - } - ts.Bookmark = respData.Bookmark - - err = core.WriteTimestamp(ctx, ts) - if err != nil { - return errors.Wrap(err, "Failed to update bookmark") - } + tx.Commit() log.Success("success\n") - if err := core.ClearActionLog(ctx); err != nil { - return errors.Wrap(err, "Failed to clear the action log") - } if err := core.CheckUpdate(ctx); err != nil { log.Error(errors.Wrap(err, "automatically checking updates").Error()) @@ -125,20 +133,20 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } } -func getPayload(actions []actions.Action, timestamp infra.Timestamp) (*bytes.Buffer, error) { +func newPayload(actions []actions.Action, bookmark int) (*bytes.Buffer, error) { compressedActions, err := compressActions(actions) if err != nil { - return &bytes.Buffer{}, errors.Wrap(err, "Failed to compress actions") + return &bytes.Buffer{}, errors.Wrap(err, "compressing actions") } payload := syncPayload{ - Bookmark: timestamp.Bookmark, + Bookmark: bookmark, Actions: compressedActions, } b, err := json.Marshal(payload) if err != nil { - return &bytes.Buffer{}, errors.Wrap(err, "Failed to marshal paylaod into JSON") + return &bytes.Buffer{}, errors.Wrap(err, "marshalling paylaod into JSON") } ret := bytes.NewBuffer(b) @@ -148,7 +156,7 @@ func getPayload(actions []actions.Action, timestamp infra.Timestamp) (*bytes.Buf func compressActions(actions []actions.Action) ([]byte, error) { b, err := json.Marshal(&actions) if err != nil { - return nil, errors.Wrap(err, "failed to marshal actions into JSON") + return nil, errors.Wrap(err, "marshalling actions into JSON") } var buf bytes.Buffer @@ -156,11 +164,11 @@ func compressActions(actions []actions.Action) ([]byte, error) { _, err = g.Write(b) if err != nil { - return nil, errors.Wrap(err, "Failed to write to gzip writer") + return nil, errors.Wrap(err, "writing to gzip writer") } if err = g.Close(); err != nil { - return nil, errors.Wrap(err, "Failed to close gzip writer") + return nil, errors.Wrap(err, "closing gzip writer") } return buf.Bytes(), nil @@ -170,7 +178,7 @@ func postActions(ctx infra.DnoteCtx, APIKey string, payload io.Reader) (*http.Re endpoint := fmt.Sprintf("%s/v1/sync", ctx.APIEndpoint) req, err := http.NewRequest("POST", endpoint, payload) if err != nil { - return &http.Response{}, errors.Wrap(err, "Failed to construct HTTP request") + return &http.Response{}, errors.Wrap(err, "forming an HTTP request") } req.Header.Set("Authorization", APIKey) @@ -179,8 +187,36 @@ func postActions(ctx infra.DnoteCtx, APIKey string, payload io.Reader) (*http.Re client := http.Client{} resp, err := client.Do(req) if err != nil { - return &http.Response{}, errors.Wrap(err, "Failed to make request") + return &http.Response{}, errors.Wrap(err, "making a request") } return resp, nil } + +func getLocalActions(db *sql.DB) ([]actions.Action, error) { + ret := []actions.Action{} + + rows, err := db.Query("SELECT uuid, schema, type, data, timestamp FROM actions") + if err != nil { + return ret, errors.Wrap(err, "querying actions") + } + defer rows.Close() + + for rows.Next() { + var action actions.Action + + err = rows.Scan(&action.UUID, &action.Schema, &action.Type, &action.Data, &action.Timestamp) + if err != nil { + return ret, errors.Wrap(err, "scanning a row") + } + + ret = append(ret, action) + } + + err = rows.Err() + if err != nil { + return ret, errors.Wrap(err, "scanning rows") + } + + return ret, nil +} diff --git a/core/action.go b/core/action.go index 13526aaf..a3ca675f 100644 --- a/core/action.go +++ b/core/action.go @@ -1,16 +1,16 @@ package core import ( + "database/sql" "encoding/json" "time" "github.com/dnote/actions" - "github.com/dnote/cli/infra" "github.com/pkg/errors" - "github.com/satori/go.uuid" ) -func LogActionAddNote(ctx infra.DnoteCtx, noteUUID, bookName, content string, timestamp int64) error { +// LogActionAddNote logs an action for adding a note +func LogActionAddNote(tx *sql.Tx, noteUUID, bookName, content string, timestamp int64) error { b, err := json.Marshal(actions.AddNoteDataV2{ NoteUUID: noteUUID, BookName: bookName, @@ -19,113 +19,79 @@ func LogActionAddNote(ctx infra.DnoteCtx, noteUUID, bookName, content string, ti Public: false, }) if err != nil { - return errors.Wrap(err, "Failed to marshal data into JSON") + return errors.Wrap(err, "marshalling data into JSON") } - action := actions.Action{ - UUID: uuid.NewV4().String(), - Schema: 2, - Type: actions.ActionAddNote, - Data: b, - Timestamp: timestamp, - } - - if err := LogAction(ctx, action); err != nil { - return errors.Wrapf(err, "Failed to log action type %s", actions.ActionAddNote) + if err := LogAction(tx, 2, actions.ActionAddNote, string(b), timestamp); err != nil { + return errors.Wrapf(err, "logging action") } return nil } -func LogActionRemoveNote(ctx infra.DnoteCtx, noteUUID, bookName string) error { +// LogActionRemoveNote logs an action for removing a book +func LogActionRemoveNote(tx *sql.Tx, noteUUID, bookName string) error { b, err := json.Marshal(actions.RemoveNoteDataV1{ NoteUUID: noteUUID, BookName: bookName, }) if err != nil { - return errors.Wrap(err, "Failed to marshal data into JSON") + return errors.Wrap(err, "marshalling data into JSON") } - action := actions.Action{ - UUID: uuid.NewV4().String(), - Schema: 1, - Type: actions.ActionRemoveNote, - Data: b, - Timestamp: time.Now().Unix(), - } - - if err := LogAction(ctx, action); err != nil { - return errors.Wrapf(err, "Failed to log action type %s", actions.ActionRemoveNote) + ts := time.Now().Unix() + if err := LogAction(tx, 1, actions.ActionRemoveNote, string(b), ts); err != nil { + return errors.Wrapf(err, "logging action") } return nil } -func LogActionEditNote(ctx infra.DnoteCtx, noteUUID, bookName, content string, ts int64) error { +// LogActionEditNote logs an action for editing a note +func LogActionEditNote(tx *sql.Tx, noteUUID, bookName, content string, ts int64) error { b, err := json.Marshal(actions.EditNoteDataV2{ NoteUUID: noteUUID, FromBook: bookName, Content: &content, }) - if err != nil { - return errors.Wrap(err, "Failed to marshal data into JSON") + return errors.Wrap(err, "marshalling data into JSON") } - action := actions.Action{ - UUID: uuid.NewV4().String(), - Schema: 2, - Type: actions.ActionEditNote, - Data: b, - Timestamp: ts, - } - - if err := LogAction(ctx, action); err != nil { - return errors.Wrapf(err, "Failed to log action type %s", actions.ActionEditNote) + if err := LogAction(tx, 2, actions.ActionEditNote, string(b), ts); err != nil { + return errors.Wrapf(err, "logging action") } return nil } -func LogActionAddBook(ctx infra.DnoteCtx, name string) error { +// LogActionAddBook logs an action for adding a book +func LogActionAddBook(tx *sql.Tx, name string) error { b, err := json.Marshal(actions.AddBookDataV1{ BookName: name, }) if err != nil { - return errors.Wrap(err, "Failed to marshal data into JSON") + return errors.Wrap(err, "marshalling data into JSON") } - action := actions.Action{ - UUID: uuid.NewV4().String(), - Schema: 1, - Type: actions.ActionAddBook, - Data: b, - Timestamp: time.Now().Unix(), - } - - if err := LogAction(ctx, action); err != nil { - return errors.Wrapf(err, "Failed to log action type %s", actions.ActionAddBook) + ts := time.Now().Unix() + if err := LogAction(tx, 1, actions.ActionAddBook, string(b), ts); err != nil { + return errors.Wrapf(err, "logging action") } return nil } -func LogActionRemoveBook(ctx infra.DnoteCtx, name string) error { +// LogActionRemoveBook logs an action for removing book +func LogActionRemoveBook(tx *sql.Tx, name string) error { b, err := json.Marshal(actions.RemoveBookDataV1{BookName: name}) if err != nil { - return errors.Wrap(err, "Failed to marshal data into JSON") + return errors.Wrap(err, "marshalling data into JSON") } - action := actions.Action{ - UUID: uuid.NewV4().String(), - Schema: 1, - Type: actions.ActionRemoveBook, - Data: b, - Timestamp: time.Now().Unix(), - } - - if err := LogAction(ctx, action); err != nil { - return errors.Wrapf(err, "Failed to log action type %s", actions.ActionRemoveBook) + ts := time.Now().Unix() + if err := LogAction(tx, 1, actions.ActionRemoveBook, string(b), ts); err != nil { + return errors.Wrapf(err, "logging action") } return nil diff --git a/core/action_test.go b/core/action_test.go index cd737248..81e0ef2b 100644 --- a/core/action_test.go +++ b/core/action_test.go @@ -11,34 +11,44 @@ import ( func TestLogActionEditNote(t *testing.T) { // Setup - ctx := testutils.InitCtx("../tmp") + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - InitFiles(ctx) + // Execute + db := ctx.DB + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) + } - if err := LogActionEditNote(ctx, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "js", "updated content", 1536168581); err != nil { + if err := LogActionEditNote(tx, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "js", "updated content", 1536168581); err != nil { t.Fatalf("Failed to perform %s", err.Error()) } - b := testutils.ReadFile(ctx, "actions") - var got []actions.Action + tx.Commit() - if err := json.Unmarshal(b, &got); err != nil { - panic(errors.Wrap(err, "unmarshalling actions")) + // Test + var actionCount int + if err := db.QueryRow("SELECT count(*) FROM actions;").Scan(&actionCount); err != nil { + panic(errors.Wrap(err, "counting actions")) + } + var action actions.Action + if err := db.QueryRow("SELECT uuid, schema, type, timestamp, data FROM actions"). + Scan(&action.UUID, &action.Schema, &action.Type, &action.Timestamp, &action.Data); err != nil { + panic(errors.Wrap(err, "querying action")) } - var actionData actions.EditNoteDataV2 - if err := json.Unmarshal(got[0].Data, &actionData); err != nil { + if err := json.Unmarshal(action.Data, &actionData); err != nil { panic(errors.Wrap(err, "unmarshalling action data")) } - testutils.AssertEqual(t, len(got), 1, "action length mismatch") - testutils.AssertNotEqual(t, got[0].UUID, "", "action uuid mismatch") - testutils.AssertEqual(t, got[0].Schema, 2, "action schema mismatch") - testutils.AssertEqual(t, got[0].Type, actions.ActionEditNote, "action type mismatch") - testutils.AssertNotEqual(t, got[0].Timestamp, 0, "action timestamp mismatch") + if actionCount != 1 { + t.Fatalf("action count mismatch. got %d", actionCount) + } + testutils.AssertNotEqual(t, action.UUID, "", "action uuid mismatch") + testutils.AssertEqual(t, action.Schema, 2, "action schema mismatch") + testutils.AssertEqual(t, action.Type, actions.ActionEditNote, "action type mismatch") + testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuid mismatch") testutils.AssertEqual(t, actionData.FromBook, "js", "action data from_book mismatch") testutils.AssertEqual(t, *actionData.Content, "updated content", "action data content mismatch") diff --git a/core/core.go b/core/core.go index cbc82db8..3b2a0582 100644 --- a/core/core.go +++ b/core/core.go @@ -1,34 +1,29 @@ package core import ( - "encoding/json" + "database/sql" "fmt" "io/ioutil" "os" "os/exec" "strings" - "time" - "github.com/dnote/actions" "github.com/dnote/cli/infra" - "github.com/dnote/cli/migrate" "github.com/dnote/cli/utils" "github.com/pkg/errors" + "github.com/satori/go.uuid" "github.com/spf13/cobra" "gopkg.in/yaml.v2" ) var ( - // TimestampFilename is the name of the file containing upgrade info - TimestampFilename = "timestamps" - // DnoteDirName is the name of the directory containing dnote files - DnoteDirName = ".dnote" - ConfigFilename = "dnoterc" - DnoteFilename = "dnote" - ActionFilename = "actions" + // ConfigFilename is the name of the config file + ConfigFilename = "dnoterc" + // TmpContentFilename is the name of the temporary file that holds editor input TmpContentFilename = "DNOTE_TMPCONTENT" ) +// RunEFunc is a function type of dnote commands type RunEFunc func(*cobra.Command, []string) error // GetConfigPath returns the path to the dnote config file @@ -36,45 +31,29 @@ func GetConfigPath(ctx infra.DnoteCtx) string { return fmt.Sprintf("%s/%s", ctx.DnoteDir, ConfigFilename) } -// GetDnotePath returns the path to the dnote file -func GetDnotePath(ctx infra.DnoteCtx) string { - return fmt.Sprintf("%s/%s", ctx.DnoteDir, DnoteFilename) -} - -// GetTimestampPath returns the path to the file containing dnote upgrade -// information -func GetTimestampPath(ctx infra.DnoteCtx) string { - return fmt.Sprintf("%s/%s", ctx.DnoteDir, TimestampFilename) -} - -// GetActionPath returns the path to the file containing user actions -func GetActionPath(ctx infra.DnoteCtx) string { - return fmt.Sprintf("%s/%s", ctx.DnoteDir, ActionFilename) -} - // GetDnoteTmpContentPath returns the path to the temporary file containing // content being added or edited func GetDnoteTmpContentPath(ctx infra.DnoteCtx) string { return fmt.Sprintf("%s/%s", ctx.DnoteDir, TmpContentFilename) } -// initActionFile populates action file if it does not exist -func initActionFile(ctx infra.DnoteCtx) error { - path := GetActionPath(ctx) +// GetBookUUID returns a uuid of a book given a label +func GetBookUUID(ctx infra.DnoteCtx, label string) (string, error) { + db := ctx.DB - if utils.FileExists(path) { - return nil + var ret string + err := db.QueryRow("SELECT uuid FROM books WHERE label = ?", label).Scan(&ret) + if err == sql.ErrNoRows { + return ret, errors.Errorf("book '%s' not found", label) + } else if err != nil { + return ret, errors.Wrap(err, "querying the book") } - b, err := json.Marshal(&[]actions.Action{}) - if err != nil { - return errors.Wrap(err, "Failed to get initial action content") - } - - err = ioutil.WriteFile(path, b, 0644) - return err + return ret, nil } +// getEditorCommand returns the system's editor command with appropriate flags, +// if necessary, to make the command wait until editor is close to exit. func getEditorCommand() string { editor := os.Getenv("EDITOR") @@ -100,34 +79,11 @@ func getEditorCommand() string { // InitFiles creates, if necessary, the dnote directory and files inside func InitFiles(ctx infra.DnoteCtx) error { - fresh, err := isFreshInstall(ctx) - if err != nil { - return errors.Wrap(err, "Failed to check if fresh install") + if err := initDnoteDir(ctx); err != nil { + return errors.Wrap(err, "creating the dnote dir") } - - err = initDnoteDir(ctx) - if err != nil { - return errors.Wrap(err, "Failed to create dnote dir") - } - err = initConfigFile(ctx) - if err != nil { - return errors.Wrap(err, "Failed to generate config file") - } - err = initDnoteFile(ctx) - if err != nil { - return errors.Wrap(err, "Failed to create dnote file") - } - err = initTimestampFile(ctx) - if err != nil { - return errors.Wrap(err, "Failed to create dnote upgrade file") - } - err = initActionFile(ctx) - if err != nil { - return errors.Wrap(err, "Failed to create action file") - } - err = migrate.InitSchemaFile(ctx, fresh) - if err != nil { - return errors.Wrap(err, "Failed to create migration file") + if err := initConfigFile(ctx); err != nil { + return errors.Wrap(err, "generating the config file") } return nil @@ -149,12 +105,12 @@ func initConfigFile(ctx infra.DnoteCtx) error { b, err := yaml.Marshal(config) if err != nil { - return errors.Wrap(err, "Failed to marshal config into YAML") + return errors.Wrap(err, "marshalling config into YAML") } err = ioutil.WriteFile(path, b, 0644) if err != nil { - return errors.Wrapf(err, "Failed to write the config file at '%s'", path) + return errors.Wrap(err, "writing the config file") } return nil @@ -175,353 +131,59 @@ func initDnoteDir(ctx infra.DnoteCtx) error { return nil } -// initDnoteFile creates an empty dnote file -func initDnoteFile(ctx infra.DnoteCtx) error { - path := GetDnotePath(ctx) - - if utils.FileExists(path) { - return nil - } - - b, err := json.Marshal(&infra.Dnote{}) - if err != nil { - return errors.Wrap(err, "Failed to get initial dnote content") - } - - err = ioutil.WriteFile(path, b, 0644) - return err -} - -// initTimestampFile creates an empty dnote upgrade file -func initTimestampFile(ctx infra.DnoteCtx) error { - path := GetTimestampPath(ctx) - - if utils.FileExists(path) { - return nil - } - - now := time.Now().Unix() - ts := infra.Timestamp{ - LastUpgrade: now, - } - - b, err := yaml.Marshal(&ts) - if err != nil { - return errors.Wrap(err, "Failed to get initial timestamp content") - } - - err = ioutil.WriteFile(path, b, 0644) - return err -} - -// ReadTimestamp gets the content of the timestamp file -func ReadTimestamp(ctx infra.DnoteCtx) (infra.Timestamp, error) { - var ret infra.Timestamp - - path := GetTimestampPath(ctx) - b, err := ioutil.ReadFile(path) - if err != nil { - return ret, err - } - - err = yaml.Unmarshal(b, &ret) - if err != nil { - return ret, errors.Wrap(err, "Failed to unmarshal timestamp content") - } - - return ret, nil -} - -func WriteTimestamp(ctx infra.DnoteCtx, timestamp infra.Timestamp) error { - d, err := yaml.Marshal(timestamp) - if err != nil { - return errors.Wrap(err, "Failed to marshal timestamp into YAML") - } - - path := GetTimestampPath(ctx) - err = ioutil.WriteFile(path, d, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write timestamp to the file") - } - - return nil -} - -// ReadNoteContent reads the content of dnote -func ReadNoteContent(ctx infra.DnoteCtx) ([]byte, error) { - notePath := GetDnotePath(ctx) - - b, err := ioutil.ReadFile(notePath) - if err != nil { - return nil, err - } - - return b, nil -} - -// GetDnote reads and parses the dnote -func GetDnote(ctx infra.DnoteCtx) (infra.Dnote, error) { - ret := infra.Dnote{} - - b, err := ReadNoteContent(ctx) - if err != nil { - return ret, errors.Wrap(err, "Failed to read note content") - } - - err = json.Unmarshal(b, &ret) - if err != nil { - return ret, errors.Wrap(err, "Failed to unmarshal note content") - } - - return ret, nil -} - -// WriteDnote persists the state of Dnote into the dnote file -func WriteDnote(ctx infra.DnoteCtx, dnote infra.Dnote) error { - d, err := json.MarshalIndent(dnote, "", " ") - if err != nil { - return err - } - - notePath := GetDnotePath(ctx) - - err = ioutil.WriteFile(notePath, d, 0644) - if err != nil { - errors.Wrap(err, "Failed to write to the dnote file") - } - - return nil -} - +// WriteConfig writes the config to the config file func WriteConfig(ctx infra.DnoteCtx, config infra.Config) error { d, err := yaml.Marshal(config) if err != nil { - return err + return errors.Wrap(err, "marhsalling config") } configPath := GetConfigPath(ctx) err = ioutil.WriteFile(configPath, d, 0644) if err != nil { - errors.Wrap(err, "Failed to write to the config file") + errors.Wrap(err, "writing the config file") } return nil } -// LogAction appends the action to the action log and updates the last_action -// timestamp -func LogAction(ctx infra.DnoteCtx, action actions.Action) error { - actions, err := ReadActionLog(ctx) +// LogAction logs action and updates the last_action +func LogAction(tx *sql.Tx, schema int, actionType, data string, timestamp int64) error { + uuid := uuid.NewV4().String() + + _, err := tx.Exec(`INSERT INTO actions (uuid, schema, type, data, timestamp) + VALUES (?, ?, ?, ?, ?)`, uuid, schema, actionType, data, timestamp) if err != nil { - return errors.Wrap(err, "Failed to read the action log") + return errors.Wrap(err, "inserting an action") } - actions = append(actions, action) - - err = WriteActionLog(ctx, actions) + _, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", timestamp, "last_action") if err != nil { - return errors.Wrap(err, "Failed to write action log") - } - - err = UpdateLastActionTimestamp(ctx, action.Timestamp) - if err != nil { - return errors.Wrap(err, "Failed to update the last_action timestamp") + return errors.Wrap(err, "updating last_action") } return nil } -func WriteActionLog(ctx infra.DnoteCtx, ats []actions.Action) error { - path := GetActionPath(ctx) - - d, err := json.Marshal(ats) - if err != nil { - return errors.Wrap(err, "Failed to marshal newly generated actions to JSON") - } - - err = ioutil.WriteFile(path, d, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write to the actions file") - } - - return nil -} - -func ClearActionLog(ctx infra.DnoteCtx) error { - var content []actions.Action - - if err := WriteActionLog(ctx, content); err != nil { - return errors.Wrap(err, "Failed to write action log") - } - - return nil -} - -func ReadActionLogContent(ctx infra.DnoteCtx) ([]byte, error) { - path := GetActionPath(ctx) - - b, err := ioutil.ReadFile(path) - if err != nil { - return []byte{}, errors.Wrap(err, "Failed to read the action file") - } - - return b, nil -} - -// ReadActionLog returns the action log content -func ReadActionLog(ctx infra.DnoteCtx) ([]actions.Action, error) { - var ret []actions.Action - - b, err := ReadActionLogContent(ctx) - if err != nil { - return ret, errors.Wrap(err, "Failed to read the action log content") - } - - err = json.Unmarshal(b, &ret) - if err != nil { - return ret, errors.Wrap(err, "Failed to unmarshal action log JSON") - } - - return ret, nil -} - +// ReadConfig reads the config file func ReadConfig(ctx infra.DnoteCtx) (infra.Config, error) { var ret infra.Config configPath := GetConfigPath(ctx) b, err := ioutil.ReadFile(configPath) if err != nil { - return ret, err + return ret, errors.Wrap(err, "reading config file") } err = yaml.Unmarshal(b, &ret) if err != nil { - return ret, errors.Wrap(err, "Failed to unmarshal config YAML") + return ret, errors.Wrap(err, "unmarshalling config") } return ret, nil } -func UpdateLastActionTimestamp(ctx infra.DnoteCtx, val int64) error { - ts, err := ReadTimestamp(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read the timestamp file") - } - - ts.LastAction = val - - err = WriteTimestamp(ctx, ts) - if err != nil { - return errors.Wrap(err, "Failed to write the timestamp to the file") - } - - return nil -} - -// NewNote returns a note -func NewNote(content string, ts int64) infra.Note { - return infra.Note{ - UUID: utils.GenerateUID(), - Content: content, - AddedOn: ts, - } -} - -// NewBook returns a book -func NewBook(name string) infra.Book { - return infra.Book{ - Name: name, - Notes: make([]infra.Note, 0), - } -} - -func GetUpdatedBook(book infra.Book, notes []infra.Note) infra.Book { - b := NewBook(book.Name) - - b.Notes = notes - - return b -} - -// MigrateToDnoteDir creates dnote directory if artifacts from the previous version -// of dnote are present, and moves the artifacts to the directory. -func MigrateToDnoteDir(ctx infra.DnoteCtx) error { - homeDir := ctx.HomeDir - - temporaryDirPath := fmt.Sprintf("%s/.dnote-tmp", homeDir) - oldDnotePath := fmt.Sprintf("%s/.dnote", homeDir) - oldDnotercPath := fmt.Sprintf("%s/.dnoterc", homeDir) - oldDnoteUpgradePath := fmt.Sprintf("%s/.dnote-upgrade", homeDir) - - // Check if a dnote file exists. Return early if it does not exist, - // or exists but already a directory. - fi, err := os.Stat(oldDnotePath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - - return errors.Wrap(err, "Failed to look up old dnote path") - } - if fi.IsDir() { - return nil - } - - if err := os.Mkdir(temporaryDirPath, 0755); err != nil { - return errors.Wrap(err, "Failed to make temporary .dnote directory") - } - - // In the beta release for v0.2, backup user's .dnote - if err := utils.CopyFile(oldDnotePath, fmt.Sprintf("%s/dnote-bak-5cdde2e83", homeDir)); err != nil { - return errors.Wrap(err, "Failed to back up the old .dnote file") - } - - if err := os.Rename(oldDnotePath, fmt.Sprintf("%s/dnote", temporaryDirPath)); err != nil { - return errors.Wrap(err, "Failed to move .dnote file") - } - if err := os.Rename(oldDnotercPath, fmt.Sprintf("%s/dnoterc", temporaryDirPath)); err != nil { - return errors.Wrap(err, "Failed to move .dnoterc file") - } - if err := os.Remove(oldDnoteUpgradePath); err != nil { - return errors.Wrap(err, "Failed to delete the old upgrade file") - } - - // Now that all files are moved to the temporary dir, rename the dir to .dnote - if err := os.Rename(temporaryDirPath, fmt.Sprintf("%s/.dnote", homeDir)); err != nil { - return errors.Wrap(err, "Failed to rename temporary dir to .dnote") - } - - return nil -} - -// isFreshInstall checks if the dnote files have been initialized -func isFreshInstall(ctx infra.DnoteCtx) (bool, error) { - path := ctx.DnoteDir - - _, err := os.Stat(path) - if os.IsNotExist(err) { - return true, nil - } - if err != nil { - return false, errors.Wrap(err, "Failed to get file info for dnote directory") - } - - return false, nil -} - -func FilterNotes(notes []infra.Note, testFunc func(infra.Note) bool) []infra.Note { - var ret []infra.Note - - for _, note := range notes { - if testFunc(note) { - ret = append(ret, note) - } - } - - return ret -} - // SanitizeContent sanitizes note content func SanitizeContent(s string) string { var ret string @@ -536,10 +198,10 @@ func SanitizeContent(s string) string { return ret } -func getEditorCmd(ctx infra.DnoteCtx, fpath string) (*exec.Cmd, error) { +func newEditorCmd(ctx infra.DnoteCtx, fpath string) (*exec.Cmd, error) { config, err := ReadConfig(ctx) if err != nil { - return nil, errors.Wrap(err, "Failed to read the config") + return nil, errors.Wrap(err, "reading config") } args := strings.Fields(config.Editor) @@ -554,17 +216,17 @@ func GetEditorInput(ctx infra.DnoteCtx, fpath string, content *string) error { if !utils.FileExists(fpath) { f, err := os.Create(fpath) if err != nil { - return errors.Wrap(err, "Failed to create a temporary file for content") + return errors.Wrap(err, "creating a temporary content file") } err = f.Close() if err != nil { - return errors.Wrap(err, "Failed to close the temporary file for content") + return errors.Wrap(err, "closing the temporary content file") } } - cmd, err := getEditorCmd(ctx, fpath) + cmd, err := newEditorCmd(ctx, fpath) if err != nil { - return errors.Wrap(err, "Failed to create the editor command") + return errors.Wrap(err, "creating an editor command") } cmd.Stdin = os.Stdin @@ -573,22 +235,22 @@ func GetEditorInput(ctx infra.DnoteCtx, fpath string, content *string) error { err = cmd.Start() if err != nil { - return errors.Wrapf(err, "Failed to launch the editor") + return errors.Wrapf(err, "launching an editor") } err = cmd.Wait() if err != nil { - return errors.Wrap(err, "Failed to wait for the editor") + return errors.Wrap(err, "waiting for the editor") } b, err := ioutil.ReadFile(fpath) if err != nil { - return errors.Wrap(err, "Failed to read the file") + return errors.Wrap(err, "reading the temporary content file") } err = os.Remove(fpath) if err != nil { - return errors.Wrap(err, "Failed to remove the temporary content file") + return errors.Wrap(err, "removing the temporary content file") } raw := string(b) diff --git a/core/core_test.go b/core/core_test.go deleted file mode 100644 index 3f284d6d..00000000 --- a/core/core_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package core - -import ( - "github.com/dnote/cli/testutils" - "github.com/dnote/cli/utils" - "github.com/pkg/errors" - "io/ioutil" - "os" - "path/filepath" - "testing" -) - -func TestMigrateToDnoteDir(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - t.Run("pre v1 files exist", func(t *testing.T) { - // set up - if err := os.MkdirAll(ctx.HomeDir, 0755); err != nil { - panic(err) - } - defer func() { - if err := os.RemoveAll(ctx.HomeDir); err != nil { - panic(err) - } - }() - - dnotePath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote")) - if err != nil { - panic(errors.Wrap(err, "Failed to get absolute .dnote path").Error()) - } - dnotercPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnoterc")) - if err != nil { - panic(errors.Wrap(err, "Failed to get absolute .dnote path").Error()) - } - dnoteUpgradePath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-upgrade")) - if err != nil { - panic(errors.Wrap(err, "Failed to get absolute .dnote path").Error()) - } - - if err = ioutil.WriteFile(dnotePath, []byte{}, 0644); err != nil { - panic(errors.Wrap(err, "Failed prepare .dnote").Error()) - } - if err = ioutil.WriteFile(dnotercPath, []byte{}, 0644); err != nil { - panic(errors.Wrap(err, "Failed prepare .dnoterc").Error()) - } - if err = ioutil.WriteFile(dnoteUpgradePath, []byte{}, 0644); err != nil { - panic(errors.Wrap(err, "Failed prepare .dnote-upgrade").Error()) - } - - // execute - err = MigrateToDnoteDir(ctx) - if err != nil { - panic(errors.Wrap(err, "Failed to perform").Error()) - } - - // test - newDnotePath, err := filepath.Abs(filepath.Join(ctx.DnoteDir, "dnote")) - if err != nil { - panic(errors.Wrap(err, "Failed get new dnote path").Error()) - } - newDnotercPath, err := filepath.Abs(filepath.Join(ctx.DnoteDir, "dnoterc")) - if err != nil { - panic(errors.Wrap(err, "Failed get new dnoterc path").Error()) - } - - fi, err := os.Stat(dnotePath) - if err != nil { - panic(errors.Wrap(err, "Failed to look up file")) - } - if !fi.IsDir() { - t.Fatal(".dnote must be a directory") - } - - if utils.FileExists(dnotercPath) { - t.Error(".dnoterc must not exist in the original location") - } - if utils.FileExists(dnoteUpgradePath) { - t.Error(".dnote-upgrade must not exist in the original location") - } - if !utils.FileExists(newDnotePath) { - t.Error("dnote must exist") - } - if !utils.FileExists(newDnotercPath) { - t.Error("dnoterc must exist") - } - }) -} diff --git a/core/reducer.go b/core/reducer.go index ec28f296..8bbffc4c 100644 --- a/core/reducer.go +++ b/core/reducer.go @@ -1,20 +1,22 @@ package core import ( + "database/sql" "encoding/json" - "sort" + "fmt" "github.com/dnote/actions" "github.com/dnote/cli/infra" "github.com/dnote/cli/log" + "github.com/dnote/cli/utils" "github.com/pkg/errors" ) // ReduceAll reduces all actions -func ReduceAll(ctx infra.DnoteCtx, ats []actions.Action) error { - for _, action := range ats { - if err := Reduce(ctx, action); err != nil { - return errors.Wrap(err, "Failed to reduce action") +func ReduceAll(ctx infra.DnoteCtx, tx *sql.Tx, actionSlice []actions.Action) error { + for _, action := range actionSlice { + if err := Reduce(ctx, tx, action); err != nil { + return errors.Wrap(err, "reducing action") } } @@ -23,312 +25,205 @@ func ReduceAll(ctx infra.DnoteCtx, ats []actions.Action) error { // Reduce transitions the local dnote state by consuming the action returned // from the server -func Reduce(ctx infra.DnoteCtx, action actions.Action) error { +func Reduce(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var err error switch action.Type { case actions.ActionAddNote: - err = handleAddNote(ctx, action) + err = handleAddNote(ctx, tx, action) case actions.ActionRemoveNote: - err = handleRemoveNote(ctx, action) + err = handleRemoveNote(ctx, tx, action) case actions.ActionEditNote: - err = handleEditNote(ctx, action) + err = handleEditNote(ctx, tx, action) case actions.ActionAddBook: - err = handleAddBook(ctx, action) + err = handleAddBook(ctx, tx, action) case actions.ActionRemoveBook: - err = handleRemoveBook(ctx, action) + err = handleRemoveBook(ctx, tx, action) default: return errors.Errorf("Unsupported action %s", action.Type) } if err != nil { - return errors.Wrapf(err, "Failed to process the action %s", action.Type) + return errors.Wrapf(err, "reducing %s", action.Type) } return nil } -func handleAddNote(ctx infra.DnoteCtx, action actions.Action) error { +func getBookUUIDWithTx(tx *sql.Tx, bookLabel string) (string, error) { + var ret string + err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", bookLabel).Scan(&ret) + if err == sql.ErrNoRows { + return ret, errors.Errorf("book '%s' not found", bookLabel) + } else if err != nil { + return ret, errors.Wrap(err, "querying the book") + } + + return ret, nil +} + +func handleAddNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var data actions.AddNoteDataV1 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "Failed to parse the action data") + if err := json.Unmarshal(action.Data, &data); err != nil { + return errors.Wrap(err, "parsing the action data") } log.Debug("reducing add_note. action: %+v. data: %+v\n", action, data) - note := infra.Note{ - UUID: data.NoteUUID, - Content: data.Content, - AddedOn: action.Timestamp, - } - - dnote, err := GetDnote(ctx) + bookUUID, err := getBookUUIDWithTx(tx, data.BookName) if err != nil { - return errors.Wrap(err, "Failed to get dnote") - } - book, ok := dnote[data.BookName] - if !ok { - return errors.Errorf("Book with a name %s is not found", data.BookName) + return errors.Wrap(err, "getting book uuid") } - // Check duplicate - for _, note := range book.Notes { - if note.UUID == data.NoteUUID { - return errors.New("Duplicate note exists") - } - } - - notes := append(dnote[book.Name].Notes, note) - - sort.SliceStable(notes, func(i, j int) bool { - return notes[i].AddedOn < notes[j].AddedOn - }) - - dnote[book.Name] = GetUpdatedBook(dnote[book.Name], notes) - - err = WriteDnote(ctx, dnote) + var noteCount int + err = tx.QueryRow("SELECT count(uuid) FROM notes WHERE uuid = ? AND book_uuid = ?", data.NoteUUID, bookUUID).Scan(¬eCount) if err != nil { - return errors.Wrap(err, "Failed to write dnote") + return errors.Wrap(err, "counting note") + } + + if noteCount > 0 { + // if a duplicate exists, it is because the same action has been previously synced to the server + // but the client did not bring the bookmark up-to-date at the time because it had error reducing + // the returned actions. + // noop so that the client can update bookmark + return nil + } + + _, err = tx.Exec(`INSERT INTO notes + (uuid, book_uuid, content, added_on, public) + VALUES (?, ?, ?, ?, ?)`, data.NoteUUID, bookUUID, data.Content, action.Timestamp, false) + if err != nil { + return errors.Wrap(err, "inserting a note") } return nil } -func handleRemoveNote(ctx infra.DnoteCtx, action actions.Action) error { +func handleRemoveNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var data actions.RemoveNoteDataV1 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "Failed to parse the action data") + if err := json.Unmarshal(action.Data, &data); err != nil { + return errors.Wrap(err, "parsing the action data") } log.Debug("reducing remove_note. action: %+v. data: %+v\n", action, data) - dnote, err := GetDnote(ctx) + _, err := tx.Exec("DELETE FROM notes WHERE uuid = ?", data.NoteUUID) if err != nil { - return errors.Wrap(err, "Failed to get dnote") - } - book, ok := dnote[data.BookName] - if !ok { - return errors.Errorf("Book with a name %s is not found", data.BookName) - } - - notes := FilterNotes(book.Notes, func(note infra.Note) bool { - return note.UUID != data.NoteUUID - }) - dnote[book.Name] = GetUpdatedBook(dnote[book.Name], notes) - - err = WriteDnote(ctx, dnote) - if err != nil { - return errors.Wrap(err, "Failed to write dnote") + return errors.Wrap(err, "removing a note") } return nil } -func handleEditNoteV1(ctx infra.DnoteCtx, action actions.Action) error { - var data actions.EditNoteDataV1 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "Failed to parse the action data") +func buildEditNoteQuery(ctx infra.DnoteCtx, tx *sql.Tx, noteUUID, bookUUID string, ts int64, data actions.EditNoteDataV2) (string, []interface{}, error) { + setTmpl := "edited_on = ?" + queryArgs := []interface{}{ts} + + if data.Content != nil { + setTmpl = fmt.Sprintf("%s, content = ?", setTmpl) + queryArgs = append(queryArgs, *data.Content) } - - log.Debug("reducing edit_note v1. action: %+v. data: %+v\n", action, data) - - dnote, err := GetDnote(ctx) - if err != nil { - return errors.Wrap(err, "Failed to get dnote") + if data.Public != nil { + setTmpl = fmt.Sprintf("%s, public = ?", setTmpl) + queryArgs = append(queryArgs, *data.Public) } - fromBook, ok := dnote[data.FromBook] - if !ok { - return errors.Errorf("Origin book with a name %s is not found", data.FromBook) - } - - if data.ToBook == "" { - for idx, note := range fromBook.Notes { - if note.UUID == data.NoteUUID { - note.Content = data.Content - note.EditedOn = action.Timestamp - dnote[fromBook.Name].Notes[idx] = note - } - } - } else { - // Change the book - - toBook, ok := dnote[data.ToBook] - if !ok { - return errors.Errorf("Destination book with a name %s is not found", data.FromBook) + if data.ToBook != nil { + bookUUID, err := getBookUUIDWithTx(tx, *data.ToBook) + if err != nil { + return "", []interface{}{}, errors.Wrap(err, "getting destination book uuid") } - var index int - var note infra.Note - - // Find the note - for idx := range fromBook.Notes { - note = fromBook.Notes[idx] - - if note.UUID == data.NoteUUID { - index = idx - } - } - - note.Content = data.Content - note.EditedOn = action.Timestamp - - dnote[fromBook.Name] = GetUpdatedBook(dnote[fromBook.Name], append(fromBook.Notes[:index], fromBook.Notes[index+1:]...)) - dnote[toBook.Name] = GetUpdatedBook(dnote[toBook.Name], append(toBook.Notes, note)) + setTmpl = fmt.Sprintf("%s, book_uuid = ?", setTmpl) + queryArgs = append(queryArgs, bookUUID) } - err = WriteDnote(ctx, dnote) - if err != nil { - return errors.Wrap(err, "Failed to write dnote") - } + queryTmpl := fmt.Sprintf("UPDATE notes SET %s WHERE uuid = ? AND book_uuid = ?", setTmpl) + queryArgs = append(queryArgs, noteUUID, bookUUID) - return nil + return queryTmpl, queryArgs, nil } -func handleEditNoteV2(ctx infra.DnoteCtx, action actions.Action) error { +func handleEditNote(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var data actions.EditNoteDataV2 err := json.Unmarshal(action.Data, &data) if err != nil { - return errors.Wrap(err, "Failed to parse the action data") + return errors.Wrap(err, "parsing the action data") } log.Debug("reducing edit_note v2. action: %+v. data: %+v\n", action, data) - dnote, err := GetDnote(ctx) + bookUUID, err := getBookUUIDWithTx(tx, data.FromBook) if err != nil { - return errors.Wrap(err, "Failed to get dnote") + return errors.Wrap(err, "getting book uuid") } - fromBook, ok := dnote[data.FromBook] - if !ok { - return errors.Errorf("Origin book with a name %s is not found", data.FromBook) - } - - if data.ToBook == nil { - for idx, note := range fromBook.Notes { - if note.UUID == data.NoteUUID { - if data.Content != nil { - note.Content = *data.Content - } - if data.Public != nil { - note.Public = *data.Public - } - - note.EditedOn = action.Timestamp - dnote[fromBook.Name].Notes[idx] = note - } - } - } else { - // Change the book - toBook := *data.ToBook - - dstBook, ok := dnote[toBook] - if !ok { - return errors.Errorf("Destination book with a name %s is not found", toBook) - } - - var index int - var note infra.Note - - // Find the note - for idx := range fromBook.Notes { - note = fromBook.Notes[idx] - - if note.UUID == data.NoteUUID { - index = idx - } - } - - if data.Content != nil { - note.Content = *data.Content - } - if data.Public != nil { - note.Public = *data.Public - } - note.EditedOn = action.Timestamp - - dnote[fromBook.Name] = GetUpdatedBook(dnote[fromBook.Name], append(fromBook.Notes[:index], fromBook.Notes[index+1:]...)) - dnote[toBook] = GetUpdatedBook(dnote[toBook], append(dstBook.Notes, note)) - } - - err = WriteDnote(ctx, dnote) + queryTmpl, queryArgs, err := buildEditNoteQuery(ctx, tx, data.NoteUUID, bookUUID, action.Timestamp, data) if err != nil { - return errors.Wrap(err, "Failed to write dnote") + return errors.Wrap(err, "building edit note query") + } + _, err = tx.Exec(queryTmpl, queryArgs...) + if err != nil { + return errors.Wrap(err, "updating a note") } return nil } -func handleEditNote(ctx infra.DnoteCtx, action actions.Action) error { - if action.Schema == 1 { - return handleEditNoteV1(ctx, action) - } else if action.Schema == 2 { - return handleEditNoteV2(ctx, action) - } - - return errors.Errorf("Unsupported schema version for editing note: %d", action.Schema) -} - -func handleAddBook(ctx infra.DnoteCtx, action actions.Action) error { +func handleAddBook(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var data actions.AddBookDataV1 err := json.Unmarshal(action.Data, &data) if err != nil { - return errors.Wrap(err, "Failed to parse the action data") + return errors.Wrap(err, "parsing the action data") } log.Debug("reducing add_book. action: %+v. data: %+v\n", action, data) - dnote, err := GetDnote(ctx) + var bookCount int + err = tx.QueryRow("SELECT count(uuid) FROM books WHERE label = ?", data.BookName).Scan(&bookCount) if err != nil { - return errors.Wrap(err, "Failed to get dnote") + return errors.Wrap(err, "counting books") } - _, exists := dnote[data.BookName] - if exists { + if bookCount > 0 { // If book already exists, another machine added a book with the same name. // noop return nil } - book := infra.Book{ - Name: data.BookName, - Notes: []infra.Note{}, - } - dnote[data.BookName] = book - - err = WriteDnote(ctx, dnote) + _, err = tx.Exec("INSERT INTO books (uuid, label) VALUES (?, ?)", utils.GenerateUUID(), data.BookName) if err != nil { - return errors.Wrap(err, "Failed to write dnote") + return errors.Wrap(err, "inserting a book") } return nil } -func handleRemoveBook(ctx infra.DnoteCtx, action actions.Action) error { +func handleRemoveBook(ctx infra.DnoteCtx, tx *sql.Tx, action actions.Action) error { var data actions.RemoveBookDataV1 - err := json.Unmarshal(action.Data, &data) - if err != nil { - return errors.Wrap(err, "Failed to parse the action data") + if err := json.Unmarshal(action.Data, &data); err != nil { + return errors.Wrap(err, "parsing the action data") } log.Debug("reducing remove_book. action: %+v. data: %+v\n", action, data) - dnote, err := GetDnote(ctx) - if err != nil { - return errors.Wrap(err, "Failed to get dnote") + var bookUUID string + err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", data.BookName).Scan(&bookUUID) + if err == sql.ErrNoRows { + // If book does not exist, another client added and removed the book, making the add_book action + // obsolete. noop. + return nil + } else if err != nil { + return errors.Wrap(err, "querying the book") } - for bookName := range dnote { - if bookName == data.BookName { - delete(dnote, bookName) - } + _, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID) + if err != nil { + return errors.Wrap(err, "removing notes") } - err = WriteDnote(ctx, dnote) + _, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID) if err != nil { - return errors.Wrap(err, "Failed to write dnote") + return errors.Wrap(err, "removing a book") } return nil diff --git a/core/reducer_test.go b/core/reducer_test.go index b60c26bf..5a574460 100644 --- a/core/reducer_test.go +++ b/core/reducer_test.go @@ -5,17 +5,17 @@ import ( "testing" "github.com/dnote/actions" + "github.com/dnote/cli/infra" "github.com/dnote/cli/testutils" "github.com/pkg/errors" ) func TestReduceAddNote(t *testing.T) { // Setup - ctx := testutils.InitCtx("../tmp") + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote4.json", "dnote") + testutils.Setup1(t, ctx) // Execute b, err := json.Marshal(&actions.AddNoteDataV1{ @@ -28,23 +28,31 @@ func TestReduceAddNote(t *testing.T) { Data: b, Timestamp: 1517629805, } - if err = Reduce(ctx, action); err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) + + db := ctx.DB + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) } + if err = Reduce(ctx, tx, action); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "processing action")) + } + tx.Commit() // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } + var noteCount, jsNoteCount, linuxNoteCount int + testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) + testutils.AssertEqual(t, noteCount, 2, "notes length mismatch") + testutils.AssertEqual(t, jsNoteCount, 2, "js notes length mismatch") + testutils.AssertEqual(t, linuxNoteCount, 0, "linux notes length mismatch") - book := dnote["js"] - otherBook := dnote["linux"] - existingNote := book.Notes[0] - newNote := book.Notes[1] + var existingNote, newNote infra.Note + testutils.MustScan(t, "scanning existing note", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &existingNote.UUID, &existingNote.Content) + testutils.MustScan(t, "scanning new note", db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE uuid = ?", "06896551-8a06-4996-89cc-0d866308b0f6"), &newNote.UUID, &newNote.Content, &newNote.AddedOn) - testutils.AssertEqual(t, len(book.Notes), 2, "notes length mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 0, "other book notes length mismatch") testutils.AssertEqual(t, existingNote.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "existing note uuid mismatch") testutils.AssertEqual(t, existingNote.Content, "Booleans have toString()", "existing note content mismatch") testutils.AssertEqual(t, newNote.UUID, "06896551-8a06-4996-89cc-0d866308b0f6", "new note uuid mismatch") @@ -52,59 +60,12 @@ func TestReduceAddNote(t *testing.T) { testutils.AssertEqual(t, newNote.AddedOn, int64(1517629805), "new note added_on mismatch") } -func TestReduceAddNote_SortByAddedOn(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") - - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - - // Execute - b, err := json.Marshal(&actions.AddNoteDataV1{ - Content: "new content", - BookName: "js", - NoteUUID: "06896551-8a06-4996-89cc-0d866308b0f6", - }) - action := actions.Action{ - Type: actions.ActionAddNote, - Data: b, - Timestamp: 1515199944, - } - if err = Reduce(ctx, action); err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - book := dnote["js"] - otherBook := dnote["linux"] - note1 := book.Notes[0] - note2 := book.Notes[1] - note3 := book.Notes[2] - - testutils.AssertEqual(t, len(book.Notes), 3, "notes length mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "other book notes length mismatch") - testutils.AssertEqual(t, note1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "existing note 1 uuid mismatch") - testutils.AssertEqual(t, note1.Content, "Booleans have toString()", "existing note 1 content mismatch") - testutils.AssertEqual(t, note2.UUID, "06896551-8a06-4996-89cc-0d866308b0f6", "new note uuid mismatch") - testutils.AssertEqual(t, note2.Content, "new content", "new note content mismatch") - testutils.AssertEqual(t, note2.AddedOn, int64(1515199944), "new note added_on mismatch") - testutils.AssertEqual(t, note3.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "existing note 2 uuid mismatch") - testutils.AssertEqual(t, note3.Content, "Date object implements mathematical comparisons", "existing note 2 content mismatch") -} - func TestReduceRemoveNote(t *testing.T) { // Setup - ctx := testutils.InitCtx("../tmp") + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") + testutils.Setup2(t, ctx) // Execute b, err := json.Marshal(&actions.RemoveNoteDataV1{ @@ -116,268 +77,149 @@ func TestReduceRemoveNote(t *testing.T) { Data: b, Timestamp: 1517629805, } - if err = Reduce(ctx, action); err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) + + db := ctx.DB + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) } + if err = Reduce(ctx, tx, action); err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "processing action")) + } + tx.Commit() // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } + var bookCount, noteCount, jsNoteCount, linuxNoteCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - targetBook := dnote["js"] - otherBook := dnote["linux"] + var n1, n2 infra.Note + testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) + testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"), &n2.UUID, &n2.Content) - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, len(targetBook.Notes), 1, "target book notes length mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "other book notes length mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") + testutils.AssertEqual(t, bookCount, 2, "number of books mismatch") + testutils.AssertEqual(t, jsNoteCount, 1, "target book notes length mismatch") + testutils.AssertEqual(t, linuxNoteCount, 1, "other book notes length mismatch") + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "remaining note content mismatch") + testutils.AssertEqual(t, n2.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") + testutils.AssertEqual(t, n2.Content, "wc -l to count words", "other book remaining note content mismatch") } -func TestReduceEditNote_V1_Content(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") +func TestReduceEditNote(t *testing.T) { + testCases := []struct { + data string + expectedNoteUUID string + expectedNoteBookUUID string + expectedNoteContent string + expectedNoteAddedOn int64 + expectedNoteEditedOn int64 + expectedNotePublic bool + expectedJsNoteCount int + expectedLinuxNoteCount int + }{ + { + data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "content": "updated content"}`, + expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", + expectedNoteBookUUID: "js-book-uuid", + expectedNoteContent: "updated content", + expectedNoteAddedOn: int64(1515199951), + expectedNoteEditedOn: int64(1517629805), + expectedNotePublic: false, + expectedJsNoteCount: 2, + expectedLinuxNoteCount: 1, + }, + { + data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "public": true}`, + expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", + expectedNoteBookUUID: "js-book-uuid", + expectedNoteContent: "Date object implements mathematical comparisons", + expectedNoteAddedOn: int64(1515199951), + expectedNoteEditedOn: int64(1517629805), + expectedNotePublic: true, + expectedJsNoteCount: 2, + expectedLinuxNoteCount: 1, + }, + { + data: `{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "to_book": "linux", "content": "updated content"}`, + expectedNoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", + expectedNoteBookUUID: "linux-book-uuid", + expectedNoteContent: "updated content", + expectedNoteAddedOn: int64(1515199951), + expectedNoteEditedOn: int64(1517629805), + expectedNotePublic: false, + expectedJsNoteCount: 1, + expectedLinuxNoteCount: 2, + }} - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") + for _, tc := range testCases { + // Setup + func() { + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - // Execute - b, err := json.Marshal(&actions.EditNoteDataV1{ - FromBook: "js", - NoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - Content: "updated content", - }) - action := actions.Action{ - Type: actions.ActionEditNote, - Data: b, - Schema: 1, - Timestamp: 1517629805, + testutils.Setup2(t, ctx) + db := ctx.DB + + // Execute + action := actions.Action{ + Type: actions.ActionEditNote, + Data: json.RawMessage(tc.data), + Schema: 2, + Timestamp: 1517629805, + } + + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) + } + err = Reduce(ctx, tx, action) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "Failed to process action")) + } + + tx.Commit() + + // Test + var bookCount, noteCount, jsNoteCount, linuxNoteCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) + + var n1, n2, n3 infra.Note + testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) + testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "3e065d55-6d47-42f2-a6bf-f5844130b2d2"), &n2.UUID, &n2.Content) + testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n3.UUID, &n3.Content, &n3.AddedOn, &n3.EditedOn, &n3.Public) + + testutils.AssertEqual(t, bookCount, 2, "number of books mismatch") + testutils.AssertEqual(t, noteCount, 3, "number of notes mismatch") + testutils.AssertEqual(t, jsNoteCount, tc.expectedJsNoteCount, "js book notes length mismatch") + testutils.AssertEqual(t, linuxNoteCount, tc.expectedLinuxNoteCount, "linux book notes length mismatch") + + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "n1 mismatch") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "n1 content mismatch") + testutils.AssertEqual(t, n2.UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "n2 uuid mismatch") + testutils.AssertEqual(t, n2.Content, "wc -l to count words", "n2 content mismatch") + testutils.AssertEqual(t, n3.UUID, tc.expectedNoteUUID, "edited note uuid mismatch") + testutils.AssertEqual(t, n3.Content, tc.expectedNoteContent, "edited note content mismatch") + testutils.AssertEqual(t, n3.AddedOn, tc.expectedNoteAddedOn, "edited note added_on mismatch") + testutils.AssertEqual(t, n3.EditedOn, tc.expectedNoteEditedOn, "edited note edited_on mismatch") + testutils.AssertEqual(t, n3.Public, tc.expectedNotePublic, "edited note public mismatch") + }() } - err = Reduce(ctx, action) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - targetBook := dnote["js"] - otherBook := dnote["linux"] - - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, len(targetBook.Notes), 2, "target book notes length mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].Content, "updated content", "edited note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].EditedOn, int64(1517629805), "edited note edited_on mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "other book notes length mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") -} - -func TestReduceEditNote_V1_ChangeBook(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") - - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - - // Execute - b, err := json.Marshal(&actions.EditNoteDataV1{ - FromBook: "js", - ToBook: "linux", - NoteUUID: "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", - Content: "updated content", - }) - action := actions.Action{ - Type: actions.ActionEditNote, - Data: b, - Schema: 1, - Timestamp: 1517629805, - } - err = Reduce(ctx, action) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - targetBook := dnote["js"] - otherBook := dnote["linux"] - - if len(targetBook.Notes) != 1 { - t.Fatalf("target book length mismatch. Got %d", len(targetBook.Notes)) - } - if len(otherBook.Notes) != 2 { - t.Fatalf("other book length mismatch. Got %d", len(targetBook.Notes)) - } - - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].Content, "updated content", "edited note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].EditedOn, int64(1517629805), "edited note edited_on mismatch") -} - -func TestReduceEditNote_V2_Content(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") - - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - - // Execute - b := json.RawMessage(`{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "content": "updated content"}`) - - action := actions.Action{ - Type: actions.ActionEditNote, - Data: b, - Schema: 2, - Timestamp: 1517629805, - } - err := Reduce(ctx, action) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - targetBook := dnote["js"] - otherBook := dnote["linux"] - - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, len(targetBook.Notes), 2, "target book notes length mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].Content, "updated content", "edited note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].EditedOn, int64(1517629805), "edited note edited_on mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].Public, false, "edited note public mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "other book notes length mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") -} - -func TestReduceEditNote_V2_public(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") - - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - - // Execute - b := json.RawMessage(`{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "public": true}`) - - action := actions.Action{ - Type: actions.ActionEditNote, - Data: b, - Schema: 2, - Timestamp: 1517629805, - } - err := Reduce(ctx, action) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - targetBook := dnote["js"] - otherBook := dnote["linux"] - - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, len(targetBook.Notes), 2, "target book notes length mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].Content, "Date object implements mathematical comparisons", "edited note content mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].EditedOn, int64(1517629805), "edited note edited_on mismatch") - testutils.AssertEqual(t, targetBook.Notes[1].Public, true, "edited note public mismatch") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "other book notes length mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") -} - -func TestReduceEditNote_V2_changeBook(t *testing.T) { - // Setup - ctx := testutils.InitCtx("../tmp") - - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") - - // Execute - b := json.RawMessage(`{"note_uuid": "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "from_book": "js", "to_book": "linux", "content": "updated content"}`) - action := actions.Action{ - Type: actions.ActionEditNote, - Data: b, - Schema: 2, - Timestamp: 1517629805, - } - err := Reduce(ctx, action) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to process action")) - } - - // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - - targetBook := dnote["js"] - otherBook := dnote["linux"] - - if len(targetBook.Notes) != 1 { - t.Fatalf("target book length mismatch. Got %d", len(targetBook.Notes)) - } - if len(otherBook.Notes) != 2 { - t.Fatalf("other book length mismatch. Got %d", len(targetBook.Notes)) - } - - testutils.AssertEqual(t, len(dnote), 2, "number of books mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, targetBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].UUID, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", "other book remaining note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[0].Content, "wc -l to count words", "other book remaining note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].Content, "updated content", "edited note content mismatch") - testutils.AssertEqual(t, otherBook.Notes[1].EditedOn, int64(1517629805), "edited note edited_on mismatch") } func TestReduceAddBook(t *testing.T) { // Setup - ctx := testutils.InitCtx("../tmp") + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote4.json", "dnote") + testutils.Setup1(t, ctx) // Execute b, err := json.Marshal(&actions.AddBookDataV1{BookName: "new_book"}) @@ -386,30 +228,32 @@ func TestReduceAddBook(t *testing.T) { Data: b, Timestamp: 1517629805, } - if err = Reduce(ctx, action); err != nil { + db := ctx.DB + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) + } + if err = Reduce(ctx, tx, action); err != nil { + tx.Rollback() t.Fatal(errors.Wrap(err, "Failed to process action")) } + tx.Commit() // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } + var bookCount, newBookNoteCount int + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting note in the new book", db.QueryRow("SELECT count(*) FROM notes INNER JOIN books ON books.uuid = notes.book_uuid WHERE books.label = ?", "new_book"), &newBookNoteCount) - newBook := dnote["new_book"] - - testutils.AssertEqual(t, len(dnote), 3, "number of books mismatch") - testutils.AssertEqual(t, newBook.Name, "new_book", "new book name mismatch") - testutils.AssertEqual(t, len(newBook.Notes), 0, "new book number of notes mismatch") + testutils.AssertEqual(t, bookCount, 3, "number of books mismatch") + testutils.AssertEqual(t, newBookNoteCount, 0, "new book number of notes mismatch") } func TestReduceRemoveBook(t *testing.T) { // Setup - ctx := testutils.InitCtx("../tmp") + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - testutils.WriteFile(ctx, "../testutils/fixtures/dnote3.json", "dnote") + testutils.Setup2(t, ctx) // Execute b, err := json.Marshal(&actions.RemoveBookDataV1{BookName: "linux"}) @@ -418,23 +262,39 @@ func TestReduceRemoveBook(t *testing.T) { Data: b, Timestamp: 1517629805, } - if err = Reduce(ctx, action); err != nil { + + db := ctx.DB + tx, err := db.Begin() + if err != nil { + panic(errors.Wrap(err, "beginning a transaction")) + } + if err = Reduce(ctx, tx, action); err != nil { + tx.Rollback() t.Fatal(errors.Wrap(err, "Failed to process action")) } + tx.Commit() // Test - dnote, err := GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } + var bookCount, noteCount, jsNoteCount, linuxNoteCount int + var jsBookLabel string + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting note", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux note", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) + testutils.MustScan(t, "scanning book", db.QueryRow("SELECT label FROM books WHERE uuid = ?", "js-book-uuid"), &jsBookLabel) - remainingBook := dnote["js"] + var n1, n2 infra.Note + testutils.MustScan(t, "scanning note 1", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content) + testutils.MustScan(t, "scanning note 2", db.QueryRow("SELECT uuid, content FROM notes WHERE uuid = ?", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Content) - testutils.AssertEqual(t, len(dnote), 1, "number of books mismatch") - testutils.AssertEqual(t, remainingBook.Name, "js", "remaining book name mismatch") - testutils.AssertEqual(t, len(remainingBook.Notes), 2, "remaining book number of notes mismatch") - testutils.AssertEqual(t, remainingBook.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") - testutils.AssertEqual(t, remainingBook.Notes[0].Content, "Booleans have toString()", "remaining note content mismatch") - testutils.AssertEqual(t, remainingBook.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") - testutils.AssertEqual(t, remainingBook.Notes[1].Content, "Date object implements mathematical comparisons", "edited note content mismatch") + testutils.AssertEqual(t, bookCount, 1, "number of books mismatch") + testutils.AssertEqual(t, noteCount, 2, "number of notes mismatch") + testutils.AssertEqual(t, jsNoteCount, 2, "js note count mismatch") + testutils.AssertEqual(t, linuxNoteCount, 0, "linux note count mismatch") + testutils.AssertEqual(t, jsBookLabel, "js", "remaining book name mismatch") + + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "remaining note uuid mismatch") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "remaining note content mismatch") + testutils.AssertEqual(t, n2.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "edited note uuid mismatch") + testutils.AssertEqual(t, n2.Content, "Date object implements mathematical comparisons", "edited note content mismatch") } diff --git a/core/upgrade.go b/core/upgrade.go index 1ae18137..10985e3c 100644 --- a/core/upgrade.go +++ b/core/upgrade.go @@ -17,27 +17,26 @@ var upgradeInterval int64 = 86400 * 7 * 3 // shouldCheckUpdate checks if update should be checked func shouldCheckUpdate(ctx infra.DnoteCtx) (bool, error) { - timestamp, err := ReadTimestamp(ctx) + db := ctx.DB + + var lastUpgrade int64 + err := db.QueryRow("SELECT value FROM system WHERE key = ?", "last_upgrade").Scan(&lastUpgrade) if err != nil { - return false, errors.Wrap(err, "Failed to get timestamp content") + return false, errors.Wrap(err, "getting last_udpate") } now := time.Now().Unix() - return now-timestamp.LastUpgrade > upgradeInterval, nil + return now-lastUpgrade > upgradeInterval, nil } func touchLastUpgrade(ctx infra.DnoteCtx) error { - timestamp, err := ReadTimestamp(ctx) - if err != nil { - return errors.Wrap(err, "Failed to get timestamp content") - } + db := ctx.DB now := time.Now().Unix() - timestamp.LastUpgrade = now - - if err := WriteTimestamp(ctx, timestamp); err != nil { - return errors.Wrap(err, "Failed to write the updated timestamp to the file") + _, err := db.Exec("UPDATE system SET value = ? WHERE key = ?", now, "last_upgrade") + if err != nil { + return errors.Wrap(err, "updating last_upgrade") } return nil diff --git a/infra/main.go b/infra/main.go index c90d5bdb..cc67272e 100644 --- a/infra/main.go +++ b/infra/main.go @@ -1,12 +1,31 @@ // Package infra defines dnote structure package infra +import ( + "database/sql" + "fmt" + "os" + "os/user" + "time" + + // use sqlite + _ "github.com/mattn/go-sqlite3" + + "github.com/pkg/errors" +) + +var ( + // DnoteDirName is the name of the directory containing dnote files + DnoteDirName = ".dnote" +) + // DnoteCtx is a context holding the information of the current runtime type DnoteCtx struct { HomeDir string DnoteDir string APIEndpoint string Version string + DB *sql.DB } // Config holds dnote configuration @@ -41,3 +60,158 @@ type Timestamp struct { // timestamp of the most recent action performed by the cli LastAction int64 `yaml:"last_action"` } + +// NewCtx returns a new dnote context +func NewCtx(apiEndpoint, versionTag string) (DnoteCtx, error) { + homeDir, err := getHomeDir() + if err != nil { + return DnoteCtx{}, errors.Wrap(err, "Failed to get home dir") + } + dnoteDir := getDnoteDir(homeDir) + + dnoteDBPath := fmt.Sprintf("%s/dnote.db", dnoteDir) + db, err := sql.Open("sqlite3", dnoteDBPath) + if err != nil { + return DnoteCtx{}, errors.Wrap(err, "conntecting to db") + } + + ret := DnoteCtx{ + HomeDir: homeDir, + DnoteDir: dnoteDir, + APIEndpoint: apiEndpoint, + Version: versionTag, + DB: db, + } + + return ret, nil +} + +func getDnoteDir(homeDir string) string { + var ret string + + dnoteDirEnv := os.Getenv("DNOTE_DIR") + if dnoteDirEnv == "" { + ret = fmt.Sprintf("%s/%s", homeDir, DnoteDirName) + } else { + ret = dnoteDirEnv + } + + return ret +} + +func getHomeDir() (string, error) { + homeDirEnv := os.Getenv("DNOTE_HOME_DIR") + if homeDirEnv != "" { + return homeDirEnv, nil + } + + usr, err := user.Current() + if err != nil { + return "", errors.Wrap(err, "Failed to get current user") + } + + return usr.HomeDir, nil +} + +// InitDB initializes the database. +// Ideally this process must be a part of migration sequence. But it is performed +// seaprately because it is a prerequisite for legacy migration. +func InitDB(ctx DnoteCtx) error { + db := ctx.DB + + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + )`) + if err != nil { + return errors.Wrap(err, "creating notes table") + } + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS books + ( + uuid text PRIMARY KEY, + label text NOT NULL + )`) + if err != nil { + return errors.Wrap(err, "creating books table") + } + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + )`) + if err != nil { + return errors.Wrap(err, "creating actions table") + } + + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS system + ( + key string NOT NULL, + value text NOT NULL + )`) + if err != nil { + return errors.Wrap(err, "creating system table") + } + + _, err = db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_books_label ON books(label); + CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_uuid ON notes(uuid); + CREATE UNIQUE INDEX IF NOT EXISTS idx_books_uuid ON books(uuid); + CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_id ON notes(id); + CREATE INDEX IF NOT EXISTS idx_notes_book_uuid ON notes(book_uuid);`) + if err != nil { + return errors.Wrap(err, "creating indices") + } + + return nil +} + +// InitSystem inserts system data if missing +func InitSystem(ctx DnoteCtx) error { + db := ctx.DB + + tx, err := db.Begin() + if err != nil { + return errors.Wrap(err, "beginning a transaction") + } + + var bookmarkCount, lastUpgradeCount int + if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", "bookmark"). + Scan(&bookmarkCount); err != nil { + return errors.Wrap(err, "counting bookmarks") + } + if bookmarkCount == 0 { + _, err := tx.Exec("INSERT INTO system (key, value) VALUES (?, ?)", "bookmark", 0) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "inserting bookmark") + } + } + + if err := db.QueryRow("SELECT count(*) FROM system WHERE key = ?", "last_upgrade"). + Scan(&lastUpgradeCount); err != nil { + return errors.Wrap(err, "counting last_upgrade") + } + if lastUpgradeCount == 0 { + now := time.Now().Unix() + _, err := tx.Exec("INSERT INTO system (key, value) VALUES (?, ?)", "last_upgrade", now) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "inserting bookmark") + } + } + + tx.Commit() + + return nil +} diff --git a/main.go b/main.go index 0a7fdc21..c8f25b1b 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,12 @@ package main import ( - "fmt" "os" - "os/user" "github.com/dnote/cli/cmd/root" - "github.com/dnote/cli/core" "github.com/dnote/cli/infra" "github.com/dnote/cli/log" + _ "github.com/mattn/go-sqlite3" "github.com/pkg/errors" // commands @@ -17,6 +15,7 @@ import ( "github.com/dnote/cli/cmd/edit" "github.com/dnote/cli/cmd/login" "github.com/dnote/cli/cmd/ls" + "github.com/dnote/cli/cmd/remove" "github.com/dnote/cli/cmd/sync" "github.com/dnote/cli/cmd/version" @@ -28,14 +27,14 @@ var apiEndpoint string var versionTag = "master" func main() { - ctx, err := newCtx() + ctx, err := infra.NewCtx(apiEndpoint, versionTag) if err != nil { - panic(errors.Wrap(err, "Failed to initialize the dnote context")) + panic(errors.Wrap(err, "initializing context")) } + defer ctx.DB.Close() - err = root.Prepare(ctx) - if err != nil { - panic(errors.Wrap(err, "Failed to prepare dnote run")) + if err := root.Prepare(ctx); err != nil { + panic(errors.Wrap(err, "preparing dnote run")) } root.Register(remove.NewCmd(ctx)) @@ -53,47 +52,3 @@ func main() { os.Exit(1) } } - -func newCtx() (infra.DnoteCtx, error) { - homeDir, err := getHomeDir() - if err != nil { - return infra.DnoteCtx{}, errors.Wrap(err, "Failed to get home dir") - } - dnoteDir := getDnoteDir(homeDir) - - ret := infra.DnoteCtx{ - HomeDir: homeDir, - DnoteDir: dnoteDir, - APIEndpoint: apiEndpoint, - Version: versionTag, - } - - return ret, nil -} - -func getDnoteDir(homeDir string) string { - var ret string - - dnoteDirEnv := os.Getenv("DNOTE_DIR") - if dnoteDirEnv == "" { - ret = fmt.Sprintf("%s/%s", homeDir, core.DnoteDirName) - } else { - ret = dnoteDirEnv - } - - return ret -} - -func getHomeDir() (string, error) { - homeDirEnv := os.Getenv("DNOTE_HOME_DIR") - if homeDirEnv != "" { - return homeDirEnv, nil - } - - usr, err := user.Current() - if err != nil { - return "", errors.Wrap(err, "Failed to get current user") - } - - return usr.HomeDir, nil -} diff --git a/main_test.go b/main_test.go index 4143cf67..e4c883d6 100644 --- a/main_test.go +++ b/main_test.go @@ -1,14 +1,11 @@ package main import ( - "bytes" "encoding/json" "fmt" - "io" "log" "os" "os/exec" - "path/filepath" "testing" "github.com/pkg/errors" @@ -24,353 +21,298 @@ var binaryName = "test-dnote" func TestMain(m *testing.M) { if err := exec.Command("go", "build", "-o", binaryName).Run(); err != nil { - log.Print(errors.Wrap(err, "Failed to build a binary").Error()) + log.Print(errors.Wrap(err, "building a binary").Error()) os.Exit(1) } os.Exit(m.Run()) } -func newDnoteCmd(ctx infra.DnoteCtx, arg ...string) (*exec.Cmd, *bytes.Buffer, error) { - var stderr bytes.Buffer - - binaryPath, err := filepath.Abs(binaryName) - if err != nil { - return &exec.Cmd{}, &stderr, errors.Wrap(err, "Failed to get the absolute path to the test binary") - } - - cmd := exec.Command(binaryPath, arg...) - cmd.Env = []string{fmt.Sprintf("DNOTE_DIR=%s", ctx.DnoteDir), fmt.Sprintf("DNOTE_HOME_DIR=%s", ctx.HomeDir)} - cmd.Stderr = &stderr - - return cmd, &stderr, nil -} - -func runDnoteCmd(ctx infra.DnoteCtx, arg ...string) { - cmd, stderr, err := newDnoteCmd(ctx, arg...) - if err != nil { - panic(errors.Wrap(err, "Failed to get command").Error()) - } - - if err := cmd.Run(); err != nil { - panic(errors.Wrapf(err, "Failed to run command %s", stderr.String())) - } -} - func TestInit(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) // Execute - runDnoteCmd(ctx) + testutils.RunDnoteCmd(t, ctx, binaryName) // Test if !utils.FileExists(ctx.DnoteDir) { t.Errorf("dnote directory was not initialized") } - if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.DnoteFilename)) { - t.Errorf("dnote file was not initialized") - } if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.ConfigFilename)) { t.Errorf("config file was not initialized") } - if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.TimestampFilename)) { - t.Errorf("timestamp file was not initialized") - } - if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.ActionFilename)) { - t.Errorf("action file was not initialized") - } + + db := ctx.DB + + var notesTableCount, booksTableCount, actionsTableCount, systemTableCount int + testutils.MustScan(t, "counting notes", + db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "notes"), ¬esTableCount) + testutils.MustScan(t, "counting books", + db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "books"), &booksTableCount) + testutils.MustScan(t, "counting actions", + db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "actions"), &actionsTableCount) + testutils.MustScan(t, "counting system", + db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type = ? AND name = ?", "table", "system"), &systemTableCount) + + testutils.AssertEqual(t, notesTableCount, 1, "notes table count mismatch") + testutils.AssertEqual(t, booksTableCount, 1, "books table count mismatch") + testutils.AssertEqual(t, actionsTableCount, 1, "actions table count mismatch") + testutils.AssertEqual(t, systemTableCount, 1, "system table count mismatch") } -func TestAdd_NewBook_ContentFlag(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) +func TestAddNote_NewBook_ContentFlag(t *testing.T) { + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) // Execute - runDnoteCmd(ctx, "add", "js", "-c", "foo") + testutils.RunDnoteCmd(t, ctx, binaryName, "add", "js", "-c", "foo") // Test - dnote, err := core.GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - actionSlice, err := core.ReadActionLog(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to read actions")) - } + db := ctx.DB - if len(actionSlice) != 2 { - t.Fatalf("action log length mismatch. got %d", len(actionSlice)) - } + var actionCount, noteCount, bookCount int + testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) - book := dnote["js"] - note := book.Notes[0] - bookAction := actionSlice[0] - noteAction := actionSlice[1] + testutils.AssertEqualf(t, actionCount, 2, "action count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") + + var jsBookUUID string + testutils.MustScan(t, "getting js book uuid", db.QueryRow("SELECT uuid FROM books where label = ?", "js"), &jsBookUUID) + var note infra.Note + testutils.MustScan(t, "getting note", + db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ?", jsBookUUID), ¬e.UUID, ¬e.Content, ¬e.AddedOn) + var bookAction, noteAction actions.Action + testutils.MustScan(t, "getting book action", + db.QueryRow("SELECT data, timestamp FROM actions where type = ?", actions.ActionAddBook), &bookAction.Data, &bookAction.Timestamp) + testutils.MustScan(t, "getting note action", + db.QueryRow("SELECT data, timestamp FROM actions where type = ?", actions.ActionAddNote), ¬eAction.Data, ¬eAction.Timestamp) var noteActionData actions.AddNoteDataV1 var bookActionData actions.AddBookDataV1 - err = json.Unmarshal(bookAction.Data, &bookActionData) - if err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) + if err := json.Unmarshal(bookAction.Data, &bookActionData); err != nil { + log.Fatalf("unmarshalling the action data: %s", err) } - err = json.Unmarshal(noteAction.Data, ¬eActionData) - if err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) + if err := json.Unmarshal(noteAction.Data, ¬eActionData); err != nil { + log.Fatalf("unmarshalling the action data: %s", err) } - testutils.AssertEqual(t, bookAction.Type, actions.ActionAddBook, "bookAction type mismatch") testutils.AssertNotEqual(t, bookActionData.BookName, "", "bookAction data note_uuid mismatch") testutils.AssertNotEqual(t, bookAction.Timestamp, 0, "bookAction timestamp mismatch") - testutils.AssertEqual(t, noteAction.Type, actions.ActionAddNote, "noteAction type mismatch") testutils.AssertEqual(t, noteActionData.Content, "foo", "noteAction data name mismatch") testutils.AssertNotEqual(t, noteActionData.NoteUUID, nil, "noteAction data note_uuid mismatch") testutils.AssertNotEqual(t, noteActionData.BookName, "", "noteAction data note_uuid mismatch") testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "noteAction timestamp mismatch") - testutils.AssertEqual(t, len(book.Notes), 1, "Book should have one note") testutils.AssertNotEqual(t, note.UUID, "", "Note should have UUID") testutils.AssertEqual(t, note.Content, "foo", "Note content mismatch") testutils.AssertNotEqual(t, note.AddedOn, int64(0), "Note added_on mismatch") } -func TestAdd_ExistingBook_ContentFlag(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) +func TestAddNote_ExistingBook_ContentFlag(t *testing.T) { + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - // init files by running root command - runDnoteCmd(ctx) - testutils.WriteFile(ctx, "./testutils/fixtures/dnote1.json", "dnote") + testutils.Setup3(t, ctx) // Execute - runDnoteCmd(ctx, "add", "js", "-c", "foo") + testutils.RunDnoteCmd(t, ctx, binaryName, "add", "js", "-c", "foo") // Test - dnote, err := core.GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - actionSlice, err := core.ReadActionLog(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to read actions")) + db := ctx.DB + + var actionCount, noteCount, bookCount int + testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + + testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") + + var n1, n2 infra.Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND content = ?", "js-book-uuid", "foo"), &n2.UUID, &n2.Content, &n2.AddedOn) + var noteAction actions.Action + testutils.MustScan(t, "getting note action", + db.QueryRow("SELECT data, timestamp FROM actions WHERE type = ?", actions.ActionAddNote), ¬eAction.Data, ¬eAction.Timestamp) + + var noteActionData actions.AddNoteDataV1 + if err := json.Unmarshal(noteAction.Data, ¬eActionData); err != nil { + log.Fatalf("unmarshalling the action data: %s", err) } - book := dnote["js"] - action := actionSlice[0] - - var actionData actions.AddNoteDataV1 - err = json.Unmarshal(action.Data, &actionData) - if err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) - } - - testutils.AssertEqual(t, len(actionSlice), 1, "There should be 1 action") - testutils.AssertEqual(t, action.Type, actions.ActionAddNote, "action type mismatch") - testutils.AssertEqual(t, actionData.Content, "foo", "action data name mismatch") - testutils.AssertNotEqual(t, actionData.NoteUUID, "", "action data note_uuid mismatch") - testutils.AssertEqual(t, actionData.BookName, "js", "action data book_name mismatch") - testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, len(book.Notes), 2, "Book should have one note") - testutils.AssertNotEqual(t, book.Notes[0].UUID, "", "Note should have UUID") - testutils.AssertEqual(t, book.Notes[0].Content, "Booleans have toString()", "Note content mismatch") - testutils.AssertNotEqual(t, book.Notes[1].UUID, "", "Note should have UUID") - testutils.AssertEqual(t, book.Notes[1].Content, "foo", "Note content mismatch") + testutils.AssertEqual(t, noteActionData.Content, "foo", "action data name mismatch") + testutils.AssertNotEqual(t, noteActionData.NoteUUID, "", "action data note_uuid mismatch") + testutils.AssertEqual(t, noteActionData.BookName, "js", "action data book_name mismatch") + testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") + testutils.AssertNotEqual(t, n1.UUID, "", "Note should have UUID") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") + testutils.AssertEqual(t, n1.AddedOn, int64(1515199943), "Note added_on mismatch") + testutils.AssertNotEqual(t, n2.UUID, "", "Note should have UUID") + testutils.AssertEqual(t, n2.Content, "foo", "Note content mismatch") } -func TestEdit_ContentFlag(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) +func TestEditNote_ContentFlag(t *testing.T) { + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - // init files by running root command - runDnoteCmd(ctx) - testutils.WriteFile(ctx, "./testutils/fixtures/dnote2.json", "dnote") + testutils.Setup4(t, ctx) // Execute - runDnoteCmd(ctx, "edit", "js", "1", "-c", "foo bar") + testutils.RunDnoteCmd(t, ctx, binaryName, "edit", "js", "2", "-c", "foo bar") // Test - dnote, err := core.GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - actionSlice, err := core.ReadActionLog(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to read actions")) - } + db := ctx.DB - book := dnote["js"] - action := actionSlice[0] + var actionCount, noteCount, bookCount int + testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + + testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") + + var n1, n2 infra.Note + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "43827b9a-c2b0-4c06-a290-97991c896653"), &n1.UUID, &n1.Content, &n1.AddedOn) + testutils.MustScan(t, "getting n2", + db.QueryRow("SELECT uuid, content, added_on FROM notes where book_uuid = ? AND uuid = ?", "js-book-uuid", "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f"), &n2.UUID, &n2.Content, &n2.AddedOn) + var noteAction actions.Action + testutils.MustScan(t, "getting note action", + db.QueryRow("SELECT data, type, schema FROM actions where type = ?", actions.ActionEditNote), + ¬eAction.Data, ¬eAction.Type, ¬eAction.Schema) var actionData actions.EditNoteDataV2 - err = json.Unmarshal(action.Data, &actionData) - if err != nil { + if err := json.Unmarshal(noteAction.Data, &actionData); err != nil { log.Fatalf("Failed to unmarshal the action data: %s", err) } - testutils.AssertEqual(t, len(actionSlice), 1, "There should be 1 action") - testutils.AssertEqual(t, action.Type, actions.ActionEditNote, "action type mismatch") - testutils.AssertEqual(t, action.Schema, 2, "action schema mismatch") + testutils.AssertEqual(t, noteAction.Type, actions.ActionEditNote, "action type mismatch") + testutils.AssertEqual(t, noteAction.Schema, 2, "action schema mismatch") testutils.AssertEqual(t, *actionData.Content, "foo bar", "action data name mismatch") testutils.AssertEqual(t, actionData.FromBook, "js", "action data from_book mismatch") if actionData.ToBook != nil { t.Errorf("action data to_book mismatch. Expected %+v. Got %+v", nil, actionData.ToBook) } testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuis mismatch") - testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, len(book.Notes), 2, "Book should have one note") - testutils.AssertEqual(t, book.Notes[0].UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "Note should have UUID") - testutils.AssertEqual(t, book.Notes[0].Content, "Booleans have toString()", "Note content mismatch") - testutils.AssertEqual(t, book.Notes[1].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "Note should have UUID") - testutils.AssertEqual(t, book.Notes[1].Content, "foo bar", "Note content mismatch") - testutils.AssertNotEqual(t, book.Notes[1].EditedOn, int64(0), "Note edited_on mismatch") + testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "Note should have UUID") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") + testutils.AssertEqual(t, n2.UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "Note should have UUID") + testutils.AssertEqual(t, n2.Content, "foo bar", "Note content mismatch") + testutils.AssertNotEqual(t, n2.EditedOn, 0, "Note edited_on mismatch") } func TestRemoveNote(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - // init files by running root command - runDnoteCmd(ctx) - testutils.WriteFile(ctx, "./testutils/fixtures/dnote3.json", "dnote") + testutils.Setup2(t, ctx) // Execute - cmd, stderr, err := newDnoteCmd(ctx, "remove", "js", "0") - if err != nil { - panic(errors.Wrap(err, "Failed to get command")) - } - stdin, err := cmd.StdinPipe() - if err != nil { - panic(errors.Wrap(err, "Failed to get stdin %s")) - } - defer stdin.Close() - - // Start the program - err = cmd.Start() - if err != nil { - panic(errors.Wrap(err, "Failed to start command")) - } - - // confirm - _, err = io.WriteString(stdin, "y\n") - if err != nil { - panic(errors.Wrap(err, "Failed to write to stdin")) - } - - err = cmd.Wait() - if err != nil { - panic(errors.Wrapf(err, "Failed to run command %s", stderr.String())) - } + testutils.WaitDnoteCmd(t, ctx, testutils.UserConfirm, binaryName, "remove", "js", "1") // Test - dnote, err := core.GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - actionSlice, err := core.ReadActionLog(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to read actions")) - } + db := ctx.DB - if len(actionSlice) != 1 { - t.Fatalf("action log length mismatch. got %d", len(actionSlice)) - } + var actionCount, noteCount, bookCount, jsNoteCount, linuxNoteCount int + testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - book := dnote["js"] - otherBook := dnote["linux"] - action := actionSlice[0] + testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") + testutils.AssertEqualf(t, bookCount, 2, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 2, "note count mismatch") + testutils.AssertEqual(t, jsNoteCount, 1, "Book should have one note") + testutils.AssertEqual(t, linuxNoteCount, 1, "Other book should have one note") + + var b1, b2 infra.Book + var n1 infra.Note + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT label FROM books WHERE uuid = ?", "js-book-uuid"), + &b1.Name) + testutils.MustScan(t, "getting b2", + db.QueryRow("SELECT label FROM books WHERE uuid = ?", "linux-book-uuid"), + &b2.Name) + testutils.MustScan(t, "getting n1", + db.QueryRow("SELECT uuid, content, added_on FROM notes WHERE book_uuid = ? AND id = ?", "js-book-uuid", 2), + &n1.UUID, &n1.Content, &n1.AddedOn) + + var noteAction actions.Action + testutils.MustScan(t, "getting note action", + db.QueryRow("SELECT type, schema, data FROM actions WHERE type = ?", actions.ActionRemoveNote), ¬eAction.Type, ¬eAction.Schema, ¬eAction.Data) var actionData actions.RemoveNoteDataV1 - err = json.Unmarshal(action.Data, &actionData) - if err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) + if err := json.Unmarshal(noteAction.Data, &actionData); err != nil { + log.Fatalf("unmarshalling the action data: %s", err) } - testutils.AssertEqual(t, len(actionSlice), 1, "There should be 1 action") - testutils.AssertEqual(t, action.Type, actions.ActionRemoveNote, "action type mismatch") - testutils.AssertEqual(t, actionData.NoteUUID, "43827b9a-c2b0-4c06-a290-97991c896653", "action data note_uuid mismatch") + testutils.AssertEqual(t, b1.Name, "js", "b1 label mismatch") + testutils.AssertEqual(t, b2.Name, "linux", "b2 label mismatch") + testutils.AssertEqual(t, noteAction.Schema, 1, "action schema mismatch") + testutils.AssertEqual(t, noteAction.Type, actions.ActionRemoveNote, "action type mismatch") + testutils.AssertEqual(t, actionData.NoteUUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "action data note_uuid mismatch") testutils.AssertEqual(t, actionData.BookName, "js", "action data book_name mismatch") - testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, len(book.Notes), 1, "Book should have one note") - testutils.AssertEqual(t, len(otherBook.Notes), 1, "Other book should have one note") - testutils.AssertEqual(t, book.Notes[0].UUID, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", "Note should have UUID") - testutils.AssertEqual(t, book.Notes[0].Content, "Date object implements mathematical comparisons", "Note content mismatch") + testutils.AssertNotEqual(t, noteAction.Timestamp, 0, "action timestamp mismatch") + testutils.AssertEqual(t, n1.UUID, "43827b9a-c2b0-4c06-a290-97991c896653", "Note should have UUID") + testutils.AssertEqual(t, n1.Content, "Booleans have toString()", "Note content mismatch") } func TestRemoveBook(t *testing.T) { - // Setup - ctx := testutils.InitCtx("./tmp") - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) + // Set up + ctx := testutils.InitEnv("../tmp", "./testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) - // init files by running root command - runDnoteCmd(ctx) - testutils.WriteFile(ctx, "./testutils/fixtures/dnote3.json", "dnote") + testutils.Setup2(t, ctx) // Execute - cmd, stderr, err := newDnoteCmd(ctx, "remove", "-b", "js") - if err != nil { - panic(errors.Wrap(err, "Failed to get command")) - } - stdin, err := cmd.StdinPipe() - if err != nil { - panic(errors.Wrap(err, "Failed to get stdin %s")) - } - defer stdin.Close() - - // Start the program - err = cmd.Start() - if err != nil { - panic(errors.Wrap(err, "Failed to start command")) - } - - // confirm - _, err = io.WriteString(stdin, "y\n") - if err != nil { - panic(errors.Wrap(err, "Failed to write to stdin")) - } - - err = cmd.Wait() - if err != nil { - panic(errors.Wrapf(err, "Failed to run command %s", stderr.String())) - } + testutils.WaitDnoteCmd(t, ctx, testutils.UserConfirm, binaryName, "remove", "-b", "js") // Test - dnote, err := core.GetDnote(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to get dnote")) - } - actionSlice, err := core.ReadActionLog(ctx) - if err != nil { - t.Fatal(errors.Wrap(err, "Failed to read actions")) - } + db := ctx.DB - if len(actionSlice) != 1 { - t.Fatalf("action log length mismatch. got %d", len(actionSlice)) - } + var actionCount, noteCount, bookCount, jsNoteCount, linuxNoteCount int + testutils.MustScan(t, "counting actions", db.QueryRow("SELECT count(*) FROM actions"), &actionCount) + testutils.MustScan(t, "counting books", db.QueryRow("SELECT count(*) FROM books"), &bookCount) + testutils.MustScan(t, "counting notes", db.QueryRow("SELECT count(*) FROM notes"), ¬eCount) + testutils.MustScan(t, "counting js notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "js-book-uuid"), &jsNoteCount) + testutils.MustScan(t, "counting linux notes", db.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ?", "linux-book-uuid"), &linuxNoteCount) - book := dnote["linux"] - action := actionSlice[0] + testutils.AssertEqualf(t, actionCount, 1, "action count mismatch") + testutils.AssertEqualf(t, bookCount, 1, "book count mismatch") + testutils.AssertEqualf(t, noteCount, 1, "note count mismatch") + testutils.AssertEqual(t, jsNoteCount, 0, "some notes in book were not deleted") + testutils.AssertEqual(t, linuxNoteCount, 1, "Other book should have one note") + + var b1 infra.Book + testutils.MustScan(t, "getting b1", + db.QueryRow("SELECT label FROM books WHERE uuid = ?", "linux-book-uuid"), + &b1.Name) + + var action actions.Action + testutils.MustScan(t, "getting an action", + db.QueryRow("SELECT type, schema, data FROM actions WHERE type = ?", actions.ActionRemoveBook), &action.Type, &action.Schema, &action.Data) var actionData actions.RemoveBookDataV1 - err = json.Unmarshal(action.Data, &actionData) - if err != nil { - log.Fatalf("Failed to unmarshal the action data: %s", err) + if err := json.Unmarshal(action.Data, &actionData); err != nil { + log.Fatalf("unmarshalling the action data: %s", err) } - testutils.AssertEqual(t, len(actionSlice), 1, "There should be 1 action") testutils.AssertEqual(t, action.Type, actions.ActionRemoveBook, "action type mismatch") testutils.AssertEqual(t, actionData.BookName, "js", "action data name mismatch") testutils.AssertNotEqual(t, action.Timestamp, 0, "action timestamp mismatch") - testutils.AssertEqual(t, len(dnote), 1, "There should be 1 book") - testutils.AssertEqual(t, book.Name, "linux", "Remaining book name mismatch") - testutils.AssertEqual(t, len(book.Notes), 1, "Remaining book should have one note") + testutils.AssertEqual(t, b1.Name, "linux", "Remaining book name mismatch") } diff --git a/migrate/fixtures/8-actions.json b/migrate/fixtures/8-actions.json new file mode 100644 index 00000000..5879145a --- /dev/null +++ b/migrate/fixtures/8-actions.json @@ -0,0 +1,2 @@ +[{"uuid":"6145c1b7-f286-4d9f-b0f6-00d274baefc6","schema":1,"type":"add_book","data":{"book_name":"js"},"timestamp":1536977229},{"uuid":"c048a56b-179c-4f31-9995-81e9b32b7dd6","schema":2,"type":"add_note","data":{"note_uuid":"35cbcab1-6a2a-4cc8-97e0-e73bbbd54626","book_name":"js","content":"js test 1","public":false},"timestamp":1536977229},{"uuid":"f557ef48-c304-47dc-adfb-46b7306e701f","schema":2,"type":"add_note","data":{"note_uuid":"7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a","book_name":"js","content":"js test 2","public":false},"timestamp":1536977230},{"uuid":"8d79db34-343d-4331-ae5b-24743f17ca7f","schema":2,"type":"add_note","data":{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js","content":"js test 3","public":false},"timestamp":1536977234},{"uuid":"b9c1ed4a-e6b3-41f2-983b-593ec7b8b7a1","schema":1,"type":"add_book","data":{"book_name":"css"},"timestamp":1536977237},{"uuid":"06ed7ef0-f171-4bd7-ae8e-97b5d06a4c49","schema":2,"type":"add_note","data":{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","book_name":"css","content":"js test 3","public":false},"timestamp":1536977237},{"uuid":"7f173cef-1688-4177-a373-145fcd822b2f","schema":2,"type":"edit_note","data":{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","from_book":"css","to_book":null,"content":"css test 1","public":null},"timestamp":1536977253},{"uuid":"64352e08-aa7a-45f4-b760-b3f38b5e11fa","schema":1,"type":"add_book","data":{"book_name":"sql"},"timestamp":1536977261},{"uuid":"82e20a12-bda8-45f7-ac42-b453b6daa5ec","schema":2,"type":"add_note","data":{"note_uuid":"2f47d390-685b-4b84-89ac-704c6fb8d3fb","book_name":"sql","content":"blah","public":false},"timestamp":1536977261},{"uuid":"a29055f4-ace4-44fd-8800-3396edbccaef","schema":1,"type":"remove_book","data":{"book_name":"sql"},"timestamp":1536977268},{"uuid":"871a5562-1bd0-43c1-b550-5bbb727ac7c4","schema":1,"type":"remove_note","data":{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js"},"timestamp":1536977274}] + diff --git a/migrate/fixtures/8-dnote.json b/migrate/fixtures/8-dnote.json new file mode 100644 index 00000000..ce789ff0 --- /dev/null +++ b/migrate/fixtures/8-dnote.json @@ -0,0 +1,33 @@ +{ + "css": { + "name": "css", + "notes": [ + { + "uuid": "d69edb54-5b31-4cdd-a4a5-34f0a0bfa153", + "content": "css test 1", + "added_on": 1536977237, + "edited_on": 1536977253, + "public": false + } + ] + }, + "js": { + "name": "js", + "notes": [ + { + "uuid": "35cbcab1-6a2a-4cc8-97e0-e73bbbd54626", + "content": "js test 1", + "added_on": 1536977229, + "edited_on": 0, + "public": false + }, + { + "uuid": "7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a", + "content": "js test 2", + "added_on": 1536977230, + "edited_on": 0, + "public": false + } + ] + } +} diff --git a/migrate/fixtures/8-dnoterc.yaml b/migrate/fixtures/8-dnoterc.yaml new file mode 100644 index 00000000..5f834d1e --- /dev/null +++ b/migrate/fixtures/8-dnoterc.yaml @@ -0,0 +1,3 @@ +editor: vim +apikey: "" + diff --git a/migrate/fixtures/8-schema.yaml b/migrate/fixtures/8-schema.yaml new file mode 100644 index 00000000..c6a5e60e --- /dev/null +++ b/migrate/fixtures/8-schema.yaml @@ -0,0 +1 @@ +current_version: 7 diff --git a/migrate/fixtures/8-timestamps.yaml b/migrate/fixtures/8-timestamps.yaml new file mode 100644 index 00000000..e06af092 --- /dev/null +++ b/migrate/fixtures/8-timestamps.yaml @@ -0,0 +1,3 @@ +last_upgrade: 1536977220 +bookmark: 9 +last_action: 1536977274 diff --git a/migrate/legacy.go b/migrate/legacy.go new file mode 100644 index 00000000..f5037192 --- /dev/null +++ b/migrate/legacy.go @@ -0,0 +1,950 @@ +// Package migrate provides migration logic for both sqlite and +// legacy JSON-based notes used until v0.4.x releases +package migrate + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "time" + + "github.com/dnote/cli/infra" + "github.com/dnote/cli/utils" + "github.com/pkg/errors" + "github.com/satori/go.uuid" + "gopkg.in/yaml.v2" +) + +var ( + schemaFilename = "schema" + backupDirName = ".dnote-bak" +) + +// migration IDs +const ( + _ = iota + legacyMigrationV1 + legacyMigrationV2 + legacyMigrationV3 + legacyMigrationV4 + legacyMigrationV5 + legacyMigrationV6 + legacyMigrationV7 + legacyMigrationV8 +) + +var migrationSequence = []int{ + legacyMigrationV1, + legacyMigrationV2, + legacyMigrationV3, + legacyMigrationV4, + legacyMigrationV5, + legacyMigrationV6, + legacyMigrationV7, + legacyMigrationV8, +} + +type schema struct { + currentVersion int `yaml:"current_version"` +} + +func makeSchema(complete bool) schema { + s := schema{} + + var currentVersion int + if complete { + currentVersion = len(migrationSequence) + } + + s.currentVersion = currentVersion + + return s +} + +// Legacy performs migration on JSON-based dnote if necessary +func Legacy(ctx infra.DnoteCtx) error { + // If schema does not exist, no need run a legacy migration + schemaPath := getSchemaPath(ctx) + if ok := utils.FileExists(schemaPath); !ok { + return nil + } + + unrunMigrations, err := getUnrunMigrations(ctx) + if err != nil { + return errors.Wrap(err, "Failed to get unrun migrations") + } + + for _, mid := range unrunMigrations { + if err := performMigration(ctx, mid); err != nil { + return errors.Wrapf(err, "running migration #%d", mid) + } + } + + return nil +} + +// performMigration backs up current .dnote data, performs migration, and +// restores or cleans backups depending on if there is an error +func performMigration(ctx infra.DnoteCtx, migrationID int) error { + // legacyMigrationV8 is the final migration of the legacy JSON Dnote migration + // migrate to sqlite and return + if migrationID == legacyMigrationV8 { + if err := migrateToV8(ctx); err != nil { + return errors.Wrap(err, "migrating to sqlite") + } + + return nil + } + + if err := backupDnoteDir(ctx); err != nil { + return errors.Wrap(err, "Failed to back up dnote directory") + } + + var migrationError error + + switch migrationID { + case legacyMigrationV1: + migrationError = migrateToV1(ctx) + case legacyMigrationV2: + migrationError = migrateToV2(ctx) + case legacyMigrationV3: + migrationError = migrateToV3(ctx) + case legacyMigrationV4: + migrationError = migrateToV4(ctx) + case legacyMigrationV5: + migrationError = migrateToV5(ctx) + case legacyMigrationV6: + migrationError = migrateToV6(ctx) + case legacyMigrationV7: + migrationError = migrateToV7(ctx) + default: + return errors.Errorf("Unrecognized migration id %d", migrationID) + } + + if migrationError != nil { + if err := restoreBackup(ctx); err != nil { + panic(errors.Wrap(err, "Failed to restore backup for a failed migration")) + } + + return errors.Wrapf(migrationError, "Failed to perform migration #%d", migrationID) + } + + if err := clearBackup(ctx); err != nil { + return errors.Wrap(err, "Failed to clear backup") + } + + if err := updateSchemaVersion(ctx, migrationID); err != nil { + return errors.Wrap(err, "Failed to update schema version") + } + + return nil +} + +// backupDnoteDir backs up the dnote directory to a temporary backup directory +func backupDnoteDir(ctx infra.DnoteCtx) error { + srcPath := fmt.Sprintf("%s/.dnote", ctx.HomeDir) + tmpPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) + + if err := utils.CopyDir(srcPath, tmpPath); err != nil { + return errors.Wrap(err, "Failed to copy the .dnote directory") + } + + return nil +} + +func restoreBackup(ctx infra.DnoteCtx) error { + var err error + + defer func() { + if err != nil { + log.Printf(`Failed to restore backup for a failed migration. + Don't worry. Your data is still intact in the backup directory. + Get help on https://github.com/dnote/cli/issues`) + } + }() + + srcPath := fmt.Sprintf("%s/.dnote", ctx.HomeDir) + backupPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) + + if err = os.RemoveAll(srcPath); err != nil { + return errors.Wrapf(err, "Failed to clear current dnote data at %s", backupPath) + } + + if err = os.Rename(backupPath, srcPath); err != nil { + return errors.Wrap(err, `Failed to copy backup data to the original directory.`) + } + + return nil +} + +func clearBackup(ctx infra.DnoteCtx) error { + backupPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) + + if err := os.RemoveAll(backupPath); err != nil { + return errors.Wrapf(err, "Failed to remove backup at %s", backupPath) + } + + return nil +} + +// getSchemaPath returns the path to the file containing schema info +func getSchemaPath(ctx infra.DnoteCtx) string { + return fmt.Sprintf("%s/%s", ctx.DnoteDir, schemaFilename) +} + +// initSchemaFile creates a migration file +func initSchemaFile(ctx infra.DnoteCtx, pristine bool) error { + path := getSchemaPath(ctx) + if utils.FileExists(path) { + return nil + } + + s := makeSchema(pristine) + err := writeSchema(ctx, s) + if err != nil { + return errors.Wrap(err, "Failed to write schema") + } + + return nil +} + +func readSchema(ctx infra.DnoteCtx) (schema, error) { + var ret schema + + path := getSchemaPath(ctx) + + b, err := ioutil.ReadFile(path) + if err != nil { + return ret, errors.Wrap(err, "Failed to read schema file") + } + + err = yaml.Unmarshal(b, &ret) + if err != nil { + return ret, errors.Wrap(err, "Failed to unmarshal the schema JSON") + } + + return ret, nil +} + +func writeSchema(ctx infra.DnoteCtx, s schema) error { + path := getSchemaPath(ctx) + d, err := yaml.Marshal(&s) + if err != nil { + return errors.Wrap(err, "Failed to marshal schema into yaml") + } + + if err := ioutil.WriteFile(path, d, 0644); err != nil { + return errors.Wrap(err, "Failed to write schema file") + } + + return nil +} + +func getUnrunMigrations(ctx infra.DnoteCtx) ([]int, error) { + var ret []int + + schema, err := readSchema(ctx) + if err != nil { + return ret, errors.Wrap(err, "Failed to read schema") + } + + if schema.currentVersion == len(migrationSequence) { + return ret, nil + } + + nextVersion := schema.currentVersion + ret = migrationSequence[nextVersion:] + + return ret, nil +} + +func updateSchemaVersion(ctx infra.DnoteCtx, mID int) error { + s, err := readSchema(ctx) + if err != nil { + return errors.Wrap(err, "Failed to read schema") + } + + s.currentVersion = mID + + err = writeSchema(ctx, s) + if err != nil { + return errors.Wrap(err, "Failed to write schema") + } + + return nil +} + +/***** snapshots **/ + +// v2 +type migrateToV2PreNote struct { + UID string + Content string + AddedOn int64 +} +type migrateToV2PostNote struct { + UUID string `json:"uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"editd_on"` +} +type migrateToV2PreBook []migrateToV2PreNote +type migrateToV2PostBook struct { + Name string `json:"name"` + Notes []migrateToV2PostNote `json:"notes"` +} +type migrateToV2PreDnote map[string]migrateToV2PreBook +type migrateToV2PostDnote map[string]migrateToV2PostBook + +//v3 +var ( + migrateToV3ActionAddNote = "add_note" + migrateToV3ActionAddBook = "add_book" +) + +type migrateToV3Note struct { + UUID string `json:"uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` +} +type migrateToV3Book struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Notes []migrateToV3Note `json:"notes"` +} +type migrateToV3Dnote map[string]migrateToV3Book +type migrateToV3Action struct { + Type string `json:"type"` + Data map[string]interface{} `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +// v4 +type migrateToV4PreConfig struct { + Book string + APIKey string +} +type migrateToV4PostConfig struct { + Editor string + APIKey string +} + +// v5 +type migrateToV5AddNoteData struct { + NoteUUID string `json:"note_uuid"` + BookName string `json:"book_name"` + Content string `json:"content"` +} +type migrateToV5RemoveNoteData struct { + NoteUUID string `json:"note_uuid"` + BookName string `json:"book_name"` +} +type migrateToV5AddBookData struct { + BookName string `json:"book_name"` +} +type migrateToV5RemoveBookData struct { + BookName string `json:"book_name"` +} +type migrateToV5PreEditNoteData struct { + NoteUUID string `json:"note_uuid"` + BookName string `json:"book_name"` + Content string `json:"content"` +} +type migrateToV5PostEditNoteData struct { + NoteUUID string `json:"note_uuid"` + FromBook string `json:"from_book"` + ToBook string `json:"to_book"` + Content string `json:"content"` +} +type migrateToV5PreAction struct { + ID int `json:"id"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` +} +type migrateToV5PostAction struct { + UUID string `json:"uuid"` + Schema int `json:"schema"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +var ( + migrateToV5ActionAddNote = "add_note" + migrateToV5ActionRemoveNote = "remove_note" + migrateToV5ActionEditNote = "edit_note" + migrateToV5ActionAddBook = "add_book" + migrateToV5ActionRemoveBook = "remove_book" +) + +// v6 +type migrateToV6PreNote struct { + UUID string `json:"uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` +} +type migrateToV6PostNote struct { + UUID string `json:"uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` + // Make a pointer to test absent values + Public *bool `json:"public"` +} +type migrateToV6PreBook struct { + Name string `json:"name"` + Notes []migrateToV6PreNote `json:"notes"` +} +type migrateToV6PostBook struct { + Name string `json:"name"` + Notes []migrateToV6PostNote `json:"notes"` +} +type migrateToV6PreDnote map[string]migrateToV6PreBook +type migrateToV6PostDnote map[string]migrateToV6PostBook + +// v7 +var migrateToV7ActionTypeEditNote = "edit_note" + +type migrateToV7Action struct { + UUID string `json:"uuid"` + Schema int `json:"schema"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` +} +type migrateToV7EditNoteDataV1 struct { + NoteUUID string `json:"note_uuid"` + FromBook string `json:"from_book"` + ToBook string `json:"to_book"` + Content string `json:"content"` +} +type migrateToV7EditNoteDataV2 struct { + NoteUUID string `json:"note_uuid"` + FromBook string `json:"from_book"` + ToBook *string `json:"to_book"` + Content *string `json:"content"` + Public *bool `json:"public"` +} + +// v8 +type migrateToV8Action struct { + UUID string `json:"uuid"` + Schema int `json:"schema"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` + Timestamp int64 `json:"timestamp"` +} +type migrateToV8Note struct { + UUID string `json:"uuid"` + Content string `json:"content"` + AddedOn int64 `json:"added_on"` + EditedOn int64 `json:"edited_on"` + // Make a pointer to test absent values + Public *bool `json:"public"` +} +type migrateToV8Book struct { + Name string `json:"name"` + Notes []migrateToV8Note `json:"notes"` +} +type migrateToV8Dnote map[string]migrateToV8Book +type migrateToV8Timestamp struct { + LastUpgrade int64 `yaml:"last_upgrade"` + Bookmark int `yaml:"bookmark"` + LastAction int64 `yaml:"last_action"` +} + +var migrateToV8SystemKeyLastUpgrade = "last_upgrade" +var migrateToV8SystemKeyLastAction = "last_action" +var migrateToV8SystemKeyBookMark = "bookmark" + +/***** migrations **/ + +// migrateToV1 deletes YAML archive if exists +func migrateToV1(ctx infra.DnoteCtx) error { + yamlPath := fmt.Sprintf("%s/%s", ctx.HomeDir, ".dnote-yaml-archived") + if !utils.FileExists(yamlPath) { + return nil + } + + if err := os.Remove(yamlPath); err != nil { + return errors.Wrap(err, "Failed to delete .dnote archive") + } + + return nil +} + +func migrateToV2(ctx infra.DnoteCtx) error { + notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) + + b, err := ioutil.ReadFile(notePath) + if err != nil { + return errors.Wrap(err, "Failed to read the note file") + } + + var preDnote migrateToV2PreDnote + postDnote := migrateToV2PostDnote{} + + err = json.Unmarshal(b, &preDnote) + if err != nil { + return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") + } + + for bookName, book := range preDnote { + var notes = make([]migrateToV2PostNote, 0, len(book)) + for _, note := range book { + newNote := migrateToV2PostNote{ + UUID: uuid.NewV4().String(), + Content: note.Content, + AddedOn: note.AddedOn, + EditedOn: 0, + } + + notes = append(notes, newNote) + } + + b := migrateToV2PostBook{ + Name: bookName, + Notes: notes, + } + + postDnote[bookName] = b + } + + d, err := json.MarshalIndent(postDnote, "", " ") + if err != nil { + return errors.Wrap(err, "Failed to marshal new dnote into JSON") + } + + err = ioutil.WriteFile(notePath, d, 0644) + if err != nil { + return errors.Wrap(err, "Failed to write the new dnote into the file") + } + + return nil +} + +// migrateToV3 generates actions for existing dnote +func migrateToV3(ctx infra.DnoteCtx) error { + notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) + actionsPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) + + b, err := ioutil.ReadFile(notePath) + if err != nil { + return errors.Wrap(err, "Failed to read the note file") + } + + var dnote migrateToV3Dnote + + err = json.Unmarshal(b, &dnote) + if err != nil { + return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") + } + + var actions []migrateToV3Action + + for bookName, book := range dnote { + // Find the minimum added_on timestamp from the notes that belong to the book + // to give timstamp to the add_book action. + // Logically add_book must have happened no later than the first add_note + // to the book in order for sync to work. + minTs := time.Now().Unix() + for _, note := range book.Notes { + if note.AddedOn < minTs { + minTs = note.AddedOn + } + } + + action := migrateToV3Action{ + Type: migrateToV3ActionAddBook, + Data: map[string]interface{}{ + "book_name": bookName, + }, + Timestamp: minTs, + } + actions = append(actions, action) + + for _, note := range book.Notes { + action := migrateToV3Action{ + Type: migrateToV3ActionAddNote, + Data: map[string]interface{}{ + "note_uuid": note.UUID, + "book_name": book.Name, + "content": note.Content, + }, + Timestamp: note.AddedOn, + } + actions = append(actions, action) + } + } + + a, err := json.Marshal(actions) + if err != nil { + return errors.Wrap(err, "Failed to marshal actions into JSON") + } + + err = ioutil.WriteFile(actionsPath, a, 0644) + if err != nil { + return errors.Wrap(err, "Failed to write the actions into a file") + } + + return nil +} + +func getEditorCommand() string { + editor := os.Getenv("EDITOR") + + switch editor { + case "atom": + return "atom -w" + case "subl": + return "subl -n -w" + case "mate": + return "mate -w" + case "vim": + return "vim" + case "nano": + return "nano" + case "emacs": + return "emacs" + default: + return "vi" + } +} + +func migrateToV4(ctx infra.DnoteCtx) error { + configPath := fmt.Sprintf("%s/dnoterc", ctx.DnoteDir) + + b, err := ioutil.ReadFile(configPath) + if err != nil { + return errors.Wrap(err, "Failed to read the config file") + } + + var preConfig migrateToV4PreConfig + err = yaml.Unmarshal(b, &preConfig) + if err != nil { + return errors.Wrap(err, "Failed to unmarshal existing config into JSON") + } + + postConfig := migrateToV4PostConfig{ + APIKey: preConfig.APIKey, + Editor: getEditorCommand(), + } + + data, err := yaml.Marshal(postConfig) + if err != nil { + return errors.Wrap(err, "Failed to marshal config into JSON") + } + + err = ioutil.WriteFile(configPath, data, 0644) + if err != nil { + return errors.Wrap(err, "Failed to write the config into a file") + } + + return nil +} + +// migrateToV5 migrates actions +func migrateToV5(ctx infra.DnoteCtx) error { + actionsPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) + + b, err := ioutil.ReadFile(actionsPath) + if err != nil { + return errors.Wrap(err, "reading the actions file") + } + + var actions []migrateToV5PreAction + err = json.Unmarshal(b, &actions) + if err != nil { + return errors.Wrap(err, "unmarshalling actions from JSON") + } + + result := []migrateToV5PostAction{} + + for _, action := range actions { + var data json.RawMessage + + switch action.Type { + case migrateToV5ActionEditNote: + var oldData migrateToV5PreEditNoteData + if err = json.Unmarshal(action.Data, &oldData); err != nil { + return errors.Wrapf(err, "unmarshalling old data of an edit note action %d", action.ID) + } + + migratedData := migrateToV5PostEditNoteData{ + NoteUUID: oldData.NoteUUID, + FromBook: oldData.BookName, + Content: oldData.Content, + } + b, err = json.Marshal(migratedData) + if err != nil { + return errors.Wrap(err, "marshalling data") + } + + data = b + default: + data = action.Data + } + + migrated := migrateToV5PostAction{ + UUID: uuid.NewV4().String(), + Schema: 1, + Type: action.Type, + Data: data, + Timestamp: action.Timestamp, + } + + result = append(result, migrated) + } + + a, err := json.Marshal(result) + if err != nil { + return errors.Wrap(err, "marshalling result into JSON") + } + err = ioutil.WriteFile(actionsPath, a, 0644) + if err != nil { + return errors.Wrap(err, "writing the result into a file") + } + + return nil +} + +// migrateToV6 adds a 'public' field to notes +func migrateToV6(ctx infra.DnoteCtx) error { + notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) + + b, err := ioutil.ReadFile(notePath) + if err != nil { + return errors.Wrap(err, "Failed to read the note file") + } + + var preDnote migrateToV6PreDnote + postDnote := migrateToV6PostDnote{} + + err = json.Unmarshal(b, &preDnote) + if err != nil { + return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") + } + + for bookName, book := range preDnote { + var notes = make([]migrateToV6PostNote, 0, len(book.Notes)) + public := false + for _, note := range book.Notes { + newNote := migrateToV6PostNote{ + UUID: note.UUID, + Content: note.Content, + AddedOn: note.AddedOn, + EditedOn: note.EditedOn, + Public: &public, + } + + notes = append(notes, newNote) + } + + b := migrateToV6PostBook{ + Name: bookName, + Notes: notes, + } + + postDnote[bookName] = b + } + + d, err := json.MarshalIndent(postDnote, "", " ") + if err != nil { + return errors.Wrap(err, "Failed to marshal new dnote into JSON") + } + + err = ioutil.WriteFile(notePath, d, 0644) + if err != nil { + return errors.Wrap(err, "Failed to write the new dnote into the file") + } + + return nil +} + +// migrateToV7 migrates data of edit_note action to the proper version which is +// EditNoteDataV2. Due to a bug, edit logged actions with schema version '2' +// but with a data of EditNoteDataV1. https://github.com/dnote/cli/issues/107 +func migrateToV7(ctx infra.DnoteCtx) error { + actionPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) + + b, err := ioutil.ReadFile(actionPath) + if err != nil { + return errors.Wrap(err, "reading actions file") + } + + var preActions []migrateToV7Action + postActions := []migrateToV7Action{} + err = json.Unmarshal(b, &preActions) + if err != nil { + return errors.Wrap(err, "unmarshalling existing actions") + } + + for _, action := range preActions { + var newAction migrateToV7Action + + if action.Type == migrateToV7ActionTypeEditNote { + var oldData migrateToV7EditNoteDataV1 + if e := json.Unmarshal(action.Data, &oldData); e != nil { + return errors.Wrapf(e, "unmarshalling data of action with uuid %s", action.Data) + } + + newData := migrateToV7EditNoteDataV2{ + NoteUUID: oldData.NoteUUID, + FromBook: oldData.FromBook, + ToBook: nil, + Content: &oldData.Content, + Public: nil, + } + d, e := json.Marshal(newData) + if e != nil { + return errors.Wrapf(e, "marshalling new data of action with uuid %s", action.Data) + } + + newAction = migrateToV7Action{ + UUID: action.UUID, + Schema: action.Schema, + Type: action.Type, + Timestamp: action.Timestamp, + Data: d, + } + } else { + newAction = action + } + + postActions = append(postActions, newAction) + } + + d, err := json.Marshal(postActions) + if err != nil { + return errors.Wrap(err, "marshalling new actions") + } + + err = ioutil.WriteFile(actionPath, d, 0644) + if err != nil { + return errors.Wrap(err, "writing new actions to a file") + } + + return nil +} + +// migrateToV8 migrates dnote data to sqlite database +func migrateToV8(ctx infra.DnoteCtx) error { + tx, err := ctx.DB.Begin() + if err != nil { + return errors.Wrap(err, "beginning a transaction") + } + + // 1. Migrate the the dnote file + dnoteFilePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) + b, err := ioutil.ReadFile(dnoteFilePath) + if err != nil { + return errors.Wrap(err, "reading the notes") + } + + var dnote migrateToV8Dnote + err = json.Unmarshal(b, &dnote) + if err != nil { + return errors.Wrap(err, "unmarshalling notes to JSON") + } + + for bookName, book := range dnote { + bookUUID := uuid.NewV4().String() + _, err = tx.Exec(`INSERT INTO books (uuid, label) VALUES (?, ?)`, bookUUID, bookName) + if err != nil { + tx.Rollback() + return errors.Wrapf(err, "inserting book %s", book.Name) + } + + for _, note := range book.Notes { + _, err = tx.Exec(`INSERT INTO notes + (uuid, book_uuid, content, added_on, edited_on, public) + VALUES (?, ?, ?, ?, ?, ?) + `, note.UUID, bookUUID, note.Content, note.AddedOn, note.EditedOn, note.Public) + + if err != nil { + tx.Rollback() + return errors.Wrapf(err, "inserting the note %s", note.UUID) + } + } + } + + // 2. Migrate the actions file + actionsPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) + b, err = ioutil.ReadFile(actionsPath) + if err != nil { + return errors.Wrap(err, "reading the actions") + } + + var actions []migrateToV8Action + err = json.Unmarshal(b, &actions) + if err != nil { + return errors.Wrap(err, "unmarshalling actions from JSON") + } + + for _, action := range actions { + _, err = tx.Exec(`INSERT INTO actions + (uuid, schema, type, data, timestamp) + VALUES (?, ?, ?, ?, ?) + `, action.UUID, action.Schema, action.Type, action.Data, action.Timestamp) + + if err != nil { + tx.Rollback() + return errors.Wrapf(err, "inserting the action %s", action.UUID) + } + } + + // 3. Migrate the timestamps file + timestampsPath := fmt.Sprintf("%s/timestamps", ctx.DnoteDir) + b, err = ioutil.ReadFile(timestampsPath) + if err != nil { + return errors.Wrap(err, "reading the timestamps") + } + + var timestamp migrateToV8Timestamp + err = yaml.Unmarshal(b, ×tamp) + if err != nil { + return errors.Wrap(err, "unmarshalling timestamps from YAML") + } + + _, err = tx.Exec(`INSERT INTO system (key, value) VALUES (?, ?)`, + migrateToV8SystemKeyLastUpgrade, timestamp.LastUpgrade) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "inserting the last_upgrade value") + } + _, err = tx.Exec(`INSERT INTO system (key, value) VALUES (?, ?)`, + migrateToV8SystemKeyLastAction, timestamp.LastAction) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "inserting the last_action value") + } + _, err = tx.Exec(`INSERT INTO system (key, value) VALUES (?, ?)`, + migrateToV8SystemKeyBookMark, timestamp.Bookmark) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "inserting the bookmark value") + } + + tx.Commit() + + if err := os.RemoveAll(dnoteFilePath); err != nil { + return errors.Wrap(err, "removing the old dnote file") + } + if err := os.RemoveAll(actionsPath); err != nil { + return errors.Wrap(err, "removing the actions file") + } + if err := os.RemoveAll(timestampsPath); err != nil { + return errors.Wrap(err, "removing the timestamps file") + } + schemaPath := fmt.Sprintf("%s/schema", ctx.DnoteDir) + if err := os.RemoveAll(schemaPath); err != nil { + return errors.Wrap(err, "removing the schema file") + } + + return nil +} diff --git a/migrate/legacy_test.go b/migrate/legacy_test.go new file mode 100644 index 00000000..600ae5f6 --- /dev/null +++ b/migrate/legacy_test.go @@ -0,0 +1,590 @@ +package migrate + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/dnote/cli/testutils" + "github.com/dnote/cli/utils" + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +func TestMigrateToV1(t *testing.T) { + + t.Run("yaml exists", func(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) + if err != nil { + panic(errors.Wrap(err, "Failed to get absolute YAML path").Error()) + } + ioutil.WriteFile(yamlPath, []byte{}, 0644) + + // execute + if err := migrateToV1(ctx); err != nil { + t.Fatal(errors.Wrapf(err, "Failed to migrate").Error()) + } + + // test + if utils.FileExists(yamlPath) { + t.Fatal("YAML archive file has not been deleted") + } + }) + + t.Run("yaml does not exist", func(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) + if err != nil { + panic(errors.Wrap(err, "Failed to get absolute YAML path").Error()) + } + + // execute + if err := migrateToV1(ctx); err != nil { + t.Fatal(errors.Wrapf(err, "Failed to migrate").Error()) + } + + // test + if utils.FileExists(yamlPath) { + t.Fatal("YAML archive file must not exist") + } + }) +} + +func TestMigrateToV2(t *testing.T) { + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + testutils.CopyFixture(ctx, "./fixtures/2-pre-dnote.json", "dnote") + + // execute + if err := migrateToV2(ctx); err != nil { + t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) + } + + // test + b := testutils.ReadFile(ctx, "dnote") + + var postDnote migrateToV2PostDnote + if err := json.Unmarshal(b, &postDnote); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) + } + + for _, book := range postDnote { + testutils.AssertNotEqual(t, book.Name, "", "Book name was not populated") + + for _, note := range book.Notes { + if len(note.UUID) == 8 { + t.Errorf("Note UUID was not migrated. It has length of %d", len(note.UUID)) + } + + testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over") + testutils.AssertEqual(t, note.EditedOn, int64(0), "EditedOn was not created properly") + } + } +} + +func TestMigrateToV3(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + testutils.CopyFixture(ctx, "./fixtures/3-pre-dnote.json", "dnote") + + // execute + if err := migrateToV3(ctx); err != nil { + t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) + } + + // test + b := testutils.ReadFile(ctx, "dnote") + var postDnote migrateToV3Dnote + if err := json.Unmarshal(b, &postDnote); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) + } + + b = testutils.ReadFile(ctx, "actions") + var actions []migrateToV3Action + if err := json.Unmarshal(b, &actions); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the actions").Error()) + } + + testutils.AssertEqual(t, len(actions), 6, "actions length mismatch") + + for _, book := range postDnote { + for _, note := range book.Notes { + testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over") + } + } +} + +func TestMigrateToV4(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + defer os.Setenv("EDITOR", "") + + testutils.CopyFixture(ctx, "./fixtures/4-pre-dnoterc.yaml", "dnoterc") + + // execute + os.Setenv("EDITOR", "vim") + if err := migrateToV4(ctx); err != nil { + t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) + } + + // test + b := testutils.ReadFile(ctx, "dnoterc") + var config migrateToV4PostConfig + if err := yaml.Unmarshal(b, &config); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) + } + + testutils.AssertEqual(t, config.APIKey, "Oev6e1082ORasdf9rjkfjkasdfjhgei", "api key mismatch") + testutils.AssertEqual(t, config.Editor, "vim", "editor mismatch") +} + +func TestMigrateToV5(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + testutils.CopyFixture(ctx, "./fixtures/5-pre-actions.json", "actions") + + // execute + if err := migrateToV5(ctx); err != nil { + t.Fatal(errors.Wrap(err, "migrating").Error()) + } + + // test + var oldActions []migrateToV5PreAction + testutils.ReadJSON("./fixtures/5-pre-actions.json", &oldActions) + + b := testutils.ReadFile(ctx, "actions") + var migratedActions []migrateToV5PostAction + if err := json.Unmarshal(b, &migratedActions); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling migrated actions").Error()) + } + + if len(oldActions) != len(migratedActions) { + t.Fatalf("There were %d actions but after migration there were %d", len(oldActions), len(migratedActions)) + } + + for idx := range migratedActions { + migrated := migratedActions[idx] + old := oldActions[idx] + + testutils.AssertNotEqual(t, migrated.UUID, "", fmt.Sprintf("uuid mismatch for migrated item with index %d", idx)) + testutils.AssertEqual(t, migrated.Schema, 1, fmt.Sprintf("schema mismatch for migrated item with index %d", idx)) + testutils.AssertEqual(t, migrated.Timestamp, old.Timestamp, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx)) + testutils.AssertEqual(t, migrated.Type, old.Type, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx)) + + switch migrated.Type { + case migrateToV5ActionAddNote: + var oldData, migratedData migrateToV5AddNoteData + if err := json.Unmarshal(old.Data, &oldData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling old data").Error()) + } + if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling new data").Error()) + } + + testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) + testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx)) + testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) + case migrateToV5ActionRemoveNote: + var oldData, migratedData migrateToV5RemoveNoteData + if err := json.Unmarshal(old.Data, &oldData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling old data").Error()) + } + if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling new data").Error()) + } + + testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) + testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) + case migrateToV5ActionAddBook: + var oldData, migratedData migrateToV5AddBookData + if err := json.Unmarshal(old.Data, &oldData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling old data").Error()) + } + if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling new data").Error()) + } + + testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) + case migrateToV5ActionRemoveBook: + var oldData, migratedData migrateToV5RemoveBookData + if err := json.Unmarshal(old.Data, &oldData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling old data").Error()) + } + if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling new data").Error()) + } + + testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) + case migrateToV5ActionEditNote: + var oldData migrateToV5PreEditNoteData + var migratedData migrateToV5PostEditNoteData + if err := json.Unmarshal(old.Data, &oldData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling old data").Error()) + } + if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling new data").Error()) + } + + testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) + testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx)) + testutils.AssertEqual(t, oldData.BookName, migratedData.FromBook, "book_name should have been renamed to from_book") + testutils.AssertEqual(t, migratedData.ToBook, "", "to_book should be empty") + } + } +} + +func TestMigrateToV6(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + testutils.CopyFixture(ctx, "./fixtures/6-pre-dnote.json", "dnote") + + // execute + if err := migrateToV6(ctx); err != nil { + t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) + } + + // test + b := testutils.ReadFile(ctx, "dnote") + var got migrateToV6PostDnote + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) + } + + b = testutils.ReadFileAbs("./fixtures/6-post-dnote.json") + var expected migrateToV6PostDnote + if err := json.Unmarshal(b, &expected); err != nil { + t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) + } + + if ok := reflect.DeepEqual(expected, got); !ok { + t.Errorf("Payload does not match.\nActual: %+v\nExpected: %+v", got, expected) + } +} + +func TestMigrateToV7(t *testing.T) { + // set up + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + testutils.CopyFixture(ctx, "./fixtures/7-pre-actions.json", "actions") + + // execute + if err := migrateToV7(ctx); err != nil { + t.Fatal(errors.Wrap(err, "migrating").Error()) + } + + // test + b := testutils.ReadFile(ctx, "actions") + var got []migrateToV7Action + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling the result").Error()) + } + + b2 := testutils.ReadFileAbs("./fixtures/7-post-actions.json") + var expected []migrateToV7Action + if err := json.Unmarshal(b, &expected); err != nil { + t.Fatal(errors.Wrap(err, "unmarshalling the result into Dnote").Error()) + } + + ok, err := testutils.IsEqualJSON(b, b2) + if err != nil { + t.Fatal(errors.Wrap(err, "comparing JSON").Error()) + } + + if !ok { + t.Errorf("Result does not match.\nActual: %+v\nExpected: %+v", got, expected) + } +} + +func TestMigrateToV8(t *testing.T) { + ctx := testutils.InitEnv("../tmp", "../testutils/fixtures/schema.sql") + defer testutils.TeardownEnv(ctx) + + // set up + testutils.CopyFixture(ctx, "./fixtures/8-actions.json", "actions") + testutils.CopyFixture(ctx, "./fixtures/8-dnote.json", "dnote") + testutils.CopyFixture(ctx, "./fixtures/8-dnoterc.yaml", "dnoterc") + testutils.CopyFixture(ctx, "./fixtures/8-schema.yaml", "schema") + testutils.CopyFixture(ctx, "./fixtures/8-timestamps.yaml", "timestamps") + + // execute + if err := migrateToV8(ctx); err != nil { + t.Fatal(errors.Wrap(err, "migrating").Error()) + } + + // test + + // 1. test if files are migrated + dnoteFilePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) + dnotercPath := fmt.Sprintf("%s/dnoterc", ctx.DnoteDir) + schemaFilePath := fmt.Sprintf("%s/schema", ctx.DnoteDir) + timestampFilePath := fmt.Sprintf("%s/timestamps", ctx.DnoteDir) + if ok := utils.FileExists(dnoteFilePath); ok { + t.Errorf("%s still exists", dnoteFilePath) + } + if ok := utils.FileExists(schemaFilePath); ok { + t.Errorf("%s still exists", dnoteFilePath) + } + if ok := utils.FileExists(timestampFilePath); ok { + t.Errorf("%s still exists", dnoteFilePath) + } + if ok := utils.FileExists(dnotercPath); !ok { + t.Errorf("%s should exist", dnotercPath) + } + + // 2. test if notes and books are migrated + db := ctx.DB + + var bookCount, noteCount int + err := db.QueryRow("SELECT count(*) FROM books").Scan(&bookCount) + if err != nil { + panic(errors.Wrap(err, "counting books")) + } + err = db.QueryRow("SELECT count(*) FROM notes").Scan(¬eCount) + if err != nil { + panic(errors.Wrap(err, "counting notes")) + } + testutils.AssertEqual(t, bookCount, 2, "book count mismatch") + testutils.AssertEqual(t, noteCount, 3, "note count mismatch") + + type bookInfo struct { + label string + uuid string + } + type noteInfo struct { + id int + uuid string + bookUUID string + content string + addedOn int64 + editedOn int64 + public bool + } + + var b1, b2 bookInfo + var n1, n2, n3 noteInfo + err = db.QueryRow("SELECT label, uuid FROM books WHERE label = ?", "js").Scan(&b1.label, &b1.uuid) + if err != nil { + panic(errors.Wrap(err, "finding book 1")) + } + err = db.QueryRow("SELECT label, uuid FROM books WHERE label = ?", "css").Scan(&b2.label, &b2.uuid) + if err != nil { + panic(errors.Wrap(err, "finding book 2")) + } + err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "d69edb54-5b31-4cdd-a4a5-34f0a0bfa153").Scan(&n1.id, &n1.uuid, &n1.bookUUID, &n1.content, &n1.addedOn, &n1.editedOn, &n1.public) + if err != nil { + panic(errors.Wrap(err, "finding note 1")) + } + err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "35cbcab1-6a2a-4cc8-97e0-e73bbbd54626").Scan(&n2.id, &n2.uuid, &n2.bookUUID, &n2.content, &n2.addedOn, &n2.editedOn, &n2.public) + if err != nil { + panic(errors.Wrap(err, "finding note 2")) + } + err = db.QueryRow("SELECT id, uuid, book_uuid, content, added_on, edited_on, public FROM notes WHERE uuid = ?", "7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a").Scan(&n3.id, &n3.uuid, &n3.bookUUID, &n3.content, &n3.addedOn, &n3.editedOn, &n3.public) + if err != nil { + panic(errors.Wrap(err, "finding note 3")) + } + + testutils.AssertNotEqual(t, b1.uuid, "", "book 1 uuid should have been generated") + testutils.AssertEqual(t, b1.label, "js", "book 1 label mismatch") + testutils.AssertNotEqual(t, b2.uuid, "", "book 2 uuid should have been generated") + testutils.AssertEqual(t, b2.label, "css", "book 2 label mismatch") + + testutils.AssertEqual(t, n1.uuid, "d69edb54-5b31-4cdd-a4a5-34f0a0bfa153", "note 1 uuid mismatch") + testutils.AssertNotEqual(t, n1.id, 0, "note 1 id should have been generated") + testutils.AssertEqual(t, n1.bookUUID, b2.uuid, "note 1 book_uuid mismatch") + testutils.AssertEqual(t, n1.content, "css test 1", "note 1 content mismatch") + testutils.AssertEqual(t, n1.addedOn, int64(1536977237), "note 1 added_on mismatch") + testutils.AssertEqual(t, n1.editedOn, int64(1536977253), "note 1 edited_on mismatch") + testutils.AssertEqual(t, n1.public, false, "note 1 public mismatch") + + testutils.AssertEqual(t, n2.uuid, "35cbcab1-6a2a-4cc8-97e0-e73bbbd54626", "note 2 uuid mismatch") + testutils.AssertNotEqual(t, n2.id, 0, "note 2 id should have been generated") + testutils.AssertEqual(t, n2.bookUUID, b1.uuid, "note 2 book_uuid mismatch") + testutils.AssertEqual(t, n2.content, "js test 1", "note 2 content mismatch") + testutils.AssertEqual(t, n2.addedOn, int64(1536977229), "note 2 added_on mismatch") + testutils.AssertEqual(t, n2.editedOn, int64(0), "note 2 edited_on mismatch") + testutils.AssertEqual(t, n2.public, false, "note 2 public mismatch") + + testutils.AssertEqual(t, n3.uuid, "7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a", "note 3 uuid mismatch") + testutils.AssertNotEqual(t, n3.id, 0, "note 3 id should have been generated") + testutils.AssertEqual(t, n3.bookUUID, b1.uuid, "note 3 book_uuid mismatch") + testutils.AssertEqual(t, n3.content, "js test 2", "note 3 content mismatch") + testutils.AssertEqual(t, n3.addedOn, int64(1536977230), "note 3 added_on mismatch") + testutils.AssertEqual(t, n3.editedOn, int64(0), "note 3 edited_on mismatch") + testutils.AssertEqual(t, n3.public, false, "note 3 public mismatch") + + // 3. test if actions are migrated + var actionCount int + err = db.QueryRow("SELECT count(*) FROM actions").Scan(&actionCount) + if err != nil { + panic(errors.Wrap(err, "counting actions")) + } + + testutils.AssertEqual(t, actionCount, 11, "action count mismatch") + + type actionInfo struct { + uuid string + schema int + actionType string + data string + timestamp int + } + + var a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11 actionInfo + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "6145c1b7-f286-4d9f-b0f6-00d274baefc6").Scan(&a1.uuid, &a1.schema, &a1.actionType, &a1.data, &a1.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a1")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "c048a56b-179c-4f31-9995-81e9b32b7dd6").Scan(&a2.uuid, &a2.schema, &a2.actionType, &a2.data, &a2.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a2")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "f557ef48-c304-47dc-adfb-46b7306e701f").Scan(&a3.uuid, &a3.schema, &a3.actionType, &a3.data, &a3.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a3")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "8d79db34-343d-4331-ae5b-24743f17ca7f").Scan(&a4.uuid, &a4.schema, &a4.actionType, &a4.data, &a4.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a4")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "b9c1ed4a-e6b3-41f2-983b-593ec7b8b7a1").Scan(&a5.uuid, &a5.schema, &a5.actionType, &a5.data, &a5.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a5")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "06ed7ef0-f171-4bd7-ae8e-97b5d06a4c49").Scan(&a6.uuid, &a6.schema, &a6.actionType, &a6.data, &a6.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a6")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "7f173cef-1688-4177-a373-145fcd822b2f").Scan(&a7.uuid, &a7.schema, &a7.actionType, &a7.data, &a7.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a7")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "64352e08-aa7a-45f4-b760-b3f38b5e11fa").Scan(&a8.uuid, &a8.schema, &a8.actionType, &a8.data, &a8.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a8")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "82e20a12-bda8-45f7-ac42-b453b6daa5ec").Scan(&a9.uuid, &a9.schema, &a9.actionType, &a9.data, &a9.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a9")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "a29055f4-ace4-44fd-8800-3396edbccaef").Scan(&a10.uuid, &a10.schema, &a10.actionType, &a10.data, &a10.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a10")) + } + err = db.QueryRow("SELECT uuid, schema, type, data, timestamp FROM actions WHERE uuid = ?", "871a5562-1bd0-43c1-b550-5bbb727ac7c4").Scan(&a11.uuid, &a11.schema, &a11.actionType, &a11.data, &a11.timestamp) + if err != nil { + panic(errors.Wrap(err, "finding a11")) + } + + testutils.AssertEqual(t, a1.uuid, "6145c1b7-f286-4d9f-b0f6-00d274baefc6", "action 1 uuid mismatch") + testutils.AssertEqual(t, a1.schema, 1, "action 1 schema mismatch") + testutils.AssertEqual(t, a1.actionType, "add_book", "action 1 type mismatch") + testutils.AssertEqual(t, a1.data, `{"book_name":"js"}`, "action 1 data mismatch") + testutils.AssertEqual(t, a1.timestamp, 1536977229, "action 1 timestamp mismatch") + + testutils.AssertEqual(t, a2.uuid, "c048a56b-179c-4f31-9995-81e9b32b7dd6", "action 2 uuid mismatch") + testutils.AssertEqual(t, a2.schema, 2, "action 2 schema mismatch") + testutils.AssertEqual(t, a2.actionType, "add_note", "action 2 type mismatch") + testutils.AssertEqual(t, a2.data, `{"note_uuid":"35cbcab1-6a2a-4cc8-97e0-e73bbbd54626","book_name":"js","content":"js test 1","public":false}`, "action 2 data mismatch") + testutils.AssertEqual(t, a2.timestamp, 1536977229, "action 2 timestamp mismatch") + + testutils.AssertEqual(t, a3.uuid, "f557ef48-c304-47dc-adfb-46b7306e701f", "action 3 uuid mismatch") + testutils.AssertEqual(t, a3.schema, 2, "action 3 schema mismatch") + testutils.AssertEqual(t, a3.actionType, "add_note", "action 3 type mismatch") + testutils.AssertEqual(t, a3.data, `{"note_uuid":"7c1fcfb2-de8b-4350-88f0-fb3cbaf6630a","book_name":"js","content":"js test 2","public":false}`, "action 3 data mismatch") + testutils.AssertEqual(t, a3.timestamp, 1536977230, "action 3 timestamp mismatch") + + testutils.AssertEqual(t, a4.uuid, "8d79db34-343d-4331-ae5b-24743f17ca7f", "action 4 uuid mismatch") + testutils.AssertEqual(t, a4.schema, 2, "action 4 schema mismatch") + testutils.AssertEqual(t, a4.actionType, "add_note", "action 4 type mismatch") + testutils.AssertEqual(t, a4.data, `{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js","content":"js test 3","public":false}`, "action 4 data mismatch") + testutils.AssertEqual(t, a4.timestamp, 1536977234, "action 4 timestamp mismatch") + + testutils.AssertEqual(t, a5.uuid, "b9c1ed4a-e6b3-41f2-983b-593ec7b8b7a1", "action 5 uuid mismatch") + testutils.AssertEqual(t, a5.schema, 1, "action 5 schema mismatch") + testutils.AssertEqual(t, a5.actionType, "add_book", "action 5 type mismatch") + testutils.AssertEqual(t, a5.data, `{"book_name":"css"}`, "action 5 data mismatch") + testutils.AssertEqual(t, a5.timestamp, 1536977237, "action 5 timestamp mismatch") + + testutils.AssertEqual(t, a6.uuid, "06ed7ef0-f171-4bd7-ae8e-97b5d06a4c49", "action 6 uuid mismatch") + testutils.AssertEqual(t, a6.schema, 2, "action 6 schema mismatch") + testutils.AssertEqual(t, a6.actionType, "add_note", "action 6 type mismatch") + testutils.AssertEqual(t, a6.data, `{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","book_name":"css","content":"js test 3","public":false}`, "action 6 data mismatch") + testutils.AssertEqual(t, a6.timestamp, 1536977237, "action 6 timestamp mismatch") + + testutils.AssertEqual(t, a7.uuid, "7f173cef-1688-4177-a373-145fcd822b2f", "action 7 uuid mismatch") + testutils.AssertEqual(t, a7.schema, 2, "action 7 schema mismatch") + testutils.AssertEqual(t, a7.actionType, "edit_note", "action 7 type mismatch") + testutils.AssertEqual(t, a7.data, `{"note_uuid":"d69edb54-5b31-4cdd-a4a5-34f0a0bfa153","from_book":"css","to_book":null,"content":"css test 1","public":null}`, "action 7 data mismatch") + testutils.AssertEqual(t, a7.timestamp, 1536977253, "action 7 timestamp mismatch") + + testutils.AssertEqual(t, a8.uuid, "64352e08-aa7a-45f4-b760-b3f38b5e11fa", "action 8 uuid mismatch") + testutils.AssertEqual(t, a8.schema, 1, "action 8 schema mismatch") + testutils.AssertEqual(t, a8.actionType, "add_book", "action 8 type mismatch") + testutils.AssertEqual(t, a8.data, `{"book_name":"sql"}`, "action 8 data mismatch") + testutils.AssertEqual(t, a8.timestamp, 1536977261, "action 8 timestamp mismatch") + + testutils.AssertEqual(t, a9.uuid, "82e20a12-bda8-45f7-ac42-b453b6daa5ec", "action 9 uuid mismatch") + testutils.AssertEqual(t, a9.schema, 2, "action 9 schema mismatch") + testutils.AssertEqual(t, a9.actionType, "add_note", "action 9 type mismatch") + testutils.AssertEqual(t, a9.data, `{"note_uuid":"2f47d390-685b-4b84-89ac-704c6fb8d3fb","book_name":"sql","content":"blah","public":false}`, "action 9 data mismatch") + testutils.AssertEqual(t, a9.timestamp, 1536977261, "action 9 timestamp mismatch") + + testutils.AssertEqual(t, a10.uuid, "a29055f4-ace4-44fd-8800-3396edbccaef", "action 10 uuid mismatch") + testutils.AssertEqual(t, a10.schema, 1, "action 10 schema mismatch") + testutils.AssertEqual(t, a10.actionType, "remove_book", "action 10 type mismatch") + testutils.AssertEqual(t, a10.data, `{"book_name":"sql"}`, "action 10 data mismatch") + testutils.AssertEqual(t, a10.timestamp, 1536977268, "action 10 timestamp mismatch") + + testutils.AssertEqual(t, a11.uuid, "871a5562-1bd0-43c1-b550-5bbb727ac7c4", "action 11 uuid mismatch") + testutils.AssertEqual(t, a11.schema, 1, "action 11 schema mismatch") + testutils.AssertEqual(t, a11.actionType, "remove_note", "action 11 type mismatch") + testutils.AssertEqual(t, a11.data, `{"note_uuid":"b23a88ba-b291-4294-9795-86b394db5dcf","book_name":"js"}`, "action 11 data mismatch") + testutils.AssertEqual(t, a11.timestamp, 1536977274, "action 11 timestamp mismatch") + + // 3. test if system is migrated + var systemCount int + err = db.QueryRow("SELECT count(*) FROM system").Scan(&systemCount) + if err != nil { + panic(errors.Wrap(err, "counting system")) + } + + testutils.AssertEqual(t, systemCount, 3, "action count mismatch") + + var lastUpgrade, lastAction, bookmark int + err = db.QueryRow("SELECT value FROM system WHERE key = ?", "last_upgrade").Scan(&lastUpgrade) + if err != nil { + panic(errors.Wrap(err, "finding last_upgrade")) + } + err = db.QueryRow("SELECT value FROM system WHERE key = ?", "last_action").Scan(&lastAction) + if err != nil { + panic(errors.Wrap(err, "finding last_action")) + } + err = db.QueryRow("SELECT value FROM system WHERE key = ?", "bookmark").Scan(&bookmark) + if err != nil { + panic(errors.Wrap(err, "finding bookmark")) + } + + testutils.AssertEqual(t, lastUpgrade, 1536977220, "last_upgrade mismatch") + testutils.AssertEqual(t, lastAction, 1536977274, "last_action mismatch") + testutils.AssertEqual(t, bookmark, 9, "bookmark mismatch") +} diff --git a/migrate/migrate.go b/migrate/migrate.go index edfdc315..8a8e1663 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -1,253 +1,95 @@ package migrate import ( - "fmt" - "io/ioutil" - "log" - "os" - - "github.com/pkg/errors" - "gopkg.in/yaml.v2" + "database/sql" "github.com/dnote/cli/infra" - "github.com/dnote/cli/utils" + "github.com/dnote/cli/log" + "github.com/pkg/errors" ) -var ( - schemaFilename = "schema" - backupDirName = ".dnote-bak" -) - -// migration IDs -const ( - _ = iota - migrationV1 - migrationV2 - migrationV3 - migrationV4 - migrationV5 - migrationV6 - migrationV7 -) - -var migrationSequence = []int{ - migrationV1, - migrationV2, - migrationV3, - migrationV4, - migrationV5, - migrationV6, - migrationV7, +type migration struct { + name string + sql string } -type schema struct { - CurrentVersion int `yaml:"current_version"` -} +var migrations = []migration{} -func makeSchema(complete bool) schema { - s := schema{} +func initSchema(db *sql.DB) (int, error) { + schemaVersion := 0 - var currentVersion int - if complete { - currentVersion = len(migrationSequence) - } - - s.CurrentVersion = currentVersion - - return s -} - -// Migrate determines migrations to be run and performs them -func Migrate(ctx infra.DnoteCtx) error { - unrunMigrations, err := getUnrunMigrations(ctx) + _, err := db.Exec("INSERT INTO system (key, value) VALUES (? ,?)", "schema", schemaVersion) if err != nil { - return errors.Wrap(err, "Failed to get unrun migrations") + return schemaVersion, errors.Wrap(err, "inserting schema") } - for _, mid := range unrunMigrations { - if err := performMigration(ctx, mid); err != nil { - return errors.Wrapf(err, "Failed to run migration #%d", mid) - } - } - - return nil + return schemaVersion, nil } -// performMigration backs up current .dnote data, performs migration, and -// restores or cleans backups depending on if there is an error -func performMigration(ctx infra.DnoteCtx, migrationID int) error { - if err := backupDnoteDir(ctx); err != nil { - return errors.Wrap(err, "Failed to back up dnote directory") - } +func getSchema(db *sql.DB) (int, error) { + var ret int - var migrationError error + err := db.QueryRow("SELECT value FROM system where key = ?", "schema").Scan(&ret) + if err == sql.ErrNoRows { + ret, err = initSchema(db) - switch migrationID { - case migrationV1: - migrationError = migrateToV1(ctx) - case migrationV2: - migrationError = migrateToV2(ctx) - case migrationV3: - migrationError = migrateToV3(ctx) - case migrationV4: - migrationError = migrateToV4(ctx) - case migrationV5: - migrationError = migrateToV5(ctx) - case migrationV6: - migrationError = migrateToV6(ctx) - case migrationV7: - migrationError = migrateToV7(ctx) - default: - return errors.Errorf("Unrecognized migration id %d", migrationID) - } - - if migrationError != nil { - if err := restoreBackup(ctx); err != nil { - panic(errors.Wrap(err, "Failed to restore backup for a failed migration")) - } - - return errors.Wrapf(migrationError, "Failed to perform migration #%d", migrationID) - } - - if err := clearBackup(ctx); err != nil { - return errors.Wrap(err, "Failed to clear backup") - } - - if err := updateSchemaVersion(ctx, migrationID); err != nil { - return errors.Wrap(err, "Failed to update schema version") - } - - return nil -} - -// backupDnoteDir backs up the dnote directory to a temporary backup directory -func backupDnoteDir(ctx infra.DnoteCtx) error { - srcPath := fmt.Sprintf("%s/.dnote", ctx.HomeDir) - tmpPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) - - if err := utils.CopyDir(srcPath, tmpPath); err != nil { - return errors.Wrap(err, "Failed to copy the .dnote directory") - } - - return nil -} - -func restoreBackup(ctx infra.DnoteCtx) error { - var err error - - defer func() { if err != nil { - log.Printf(`Failed to restore backup for a failed migration. - Don't worry. Your data is still intact in the backup directory. - Get help on https://github.com/dnote/cli/issues`) + return ret, errors.Wrap(err, "initializing schema") } - }() - - srcPath := fmt.Sprintf("%s/.dnote", ctx.HomeDir) - backupPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) - - if err = os.RemoveAll(srcPath); err != nil { - return errors.Wrapf(err, "Failed to clear current dnote data at %s", backupPath) + } else if err != nil { + return ret, errors.Wrap(err, "querying schema") } - if err = os.Rename(backupPath, srcPath); err != nil { - return errors.Wrap(err, `Failed to copy backup data to the original directory.`) + return ret, nil +} + +func execute(ctx infra.DnoteCtx, nextSchema int, m migration) error { + log.Debug("running migration %s\n", m.name) + + tx, err := ctx.DB.Begin() + if err != nil { + return errors.Wrap(err, "beginning a transaction") } + _, err = tx.Exec(m.sql) + if err != nil { + tx.Rollback() + return errors.Wrap(err, "running sql") + } + + _, err = tx.Exec("UPDATE system SET value = ? WHERE key = ?", nextSchema, "schema") + if err != nil { + tx.Rollback() + return errors.Wrap(err, "incrementing schema") + } + + tx.Commit() + return nil } -func clearBackup(ctx infra.DnoteCtx) error { - backupPath := fmt.Sprintf("%s/%s", ctx.HomeDir, backupDirName) +// Run performs unrun migrations +func Run(ctx infra.DnoteCtx) error { + db := ctx.DB - if err := os.RemoveAll(backupPath); err != nil { - return errors.Wrapf(err, "Failed to remove backup at %s", backupPath) + schema, err := getSchema(db) + if err != nil { + return errors.Wrap(err, "getting the current schema") } - return nil -} + log.Debug("current schema %d\n", schema) -// getSchemaPath returns the path to the file containing schema info -func getSchemaPath(ctx infra.DnoteCtx) string { - return fmt.Sprintf("%s/%s", ctx.DnoteDir, schemaFilename) -} - -// InitSchemaFile creates a migration file -func InitSchemaFile(ctx infra.DnoteCtx, pristine bool) error { - path := getSchemaPath(ctx) - if utils.FileExists(path) { + if schema == len(migrations) { return nil } - s := makeSchema(pristine) - err := writeSchema(ctx, s) - if err != nil { - return errors.Wrap(err, "Failed to write schema") - } - - return nil -} - -func readSchema(ctx infra.DnoteCtx) (schema, error) { - var ret schema - - path := getSchemaPath(ctx) - b, err := ioutil.ReadFile(path) - if err != nil { - return ret, errors.Wrap(err, "Failed to read schema file") - } - - err = yaml.Unmarshal(b, &ret) - if err != nil { - return ret, errors.Wrap(err, "Failed to unmarshal the schema JSON") - } - - return ret, nil -} - -func writeSchema(ctx infra.DnoteCtx, s schema) error { - path := getSchemaPath(ctx) - d, err := yaml.Marshal(&s) - if err != nil { - return errors.Wrap(err, "Failed to marshal schema into yaml") - } - - if err := ioutil.WriteFile(path, d, 0644); err != nil { - return errors.Wrap(err, "Failed to write schema file") - } - - return nil -} - -func getUnrunMigrations(ctx infra.DnoteCtx) ([]int, error) { - var ret []int - - schema, err := readSchema(ctx) - if err != nil { - return ret, errors.Wrap(err, "Failed to read schema") - } - - if schema.CurrentVersion == len(migrationSequence) { - return ret, nil - } - - nextVersion := schema.CurrentVersion - ret = migrationSequence[nextVersion:] - - return ret, nil -} - -func updateSchemaVersion(ctx infra.DnoteCtx, mID int) error { - s, err := readSchema(ctx) - if err != nil { - return errors.Wrap(err, "Failed to read schema") - } - - s.CurrentVersion = mID - - err = writeSchema(ctx, s) - if err != nil { - return errors.Wrap(err, "Failed to write schema") + toRun := migrations[schema:] + + for idx, m := range toRun { + nextSchema := schema + idx + 1 + if err := execute(ctx, nextSchema, m); err != nil { + return errors.Wrapf(err, "running migration %s", m.name) + } } return nil diff --git a/migrate/migrate_test.go b/migrate/migrate_test.go index 0fb34479..9a2804f3 100644 --- a/migrate/migrate_test.go +++ b/migrate/migrate_test.go @@ -1,362 +1,9 @@ package migrate import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "reflect" "testing" - - "github.com/dnote/cli/testutils" - "github.com/dnote/cli/utils" - "github.com/pkg/errors" - "gopkg.in/yaml.v2" ) -func TestMigrateAll(t *testing.T) { - ctx := testutils.InitCtx("../tmp") +func TestExecute(t *testing.T) { - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/2-pre-dnote.json", "dnote") - testutils.WriteFile(ctx, "./fixtures/4-pre-dnoterc.yaml", "dnoterc") - if err := InitSchemaFile(ctx, false); err != nil { - panic(errors.Wrap(err, "Failed to initialize schema file")) - } - defer testutils.ClearTmp(ctx) - - // Execute - if err := Migrate(ctx); err != nil { - t.Fatalf("Failed to migrate %s", err.Error()) - } - - // Test - schema, err := readSchema(ctx) - if err != nil { - panic(errors.Wrap(err, "Failed to read the schema")) - } - - b := testutils.ReadFile(ctx, "dnote") - var dnote migrateToV3Dnote - if err := json.Unmarshal(b, &dnote); err != nil { - t.Error(errors.Wrap(err, "Failed to unmarshal result into dnote").Error()) - } - - testutils.AssertEqual(t, schema.CurrentVersion, len(migrationSequence), "current schema version mismatch") - - note := dnote["algorithm"].Notes[0] - testutils.AssertEqual(t, note.Content, "in-place means no extra space required. it mutates the input", "content was not carried over") - testutils.AssertNotEqual(t, note.UUID, "", "note uuid was not generated") - testutils.AssertNotEqual(t, note.AddedOn, int64(0), "note added_on was not generated") - testutils.AssertEqual(t, note.EditedOn, int64(0), "note edited_on was not propertly generated") -} - -func TestMigrateToV1(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - t.Run("yaml exists", func(t *testing.T) { - // set up - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - - yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) - if err != nil { - panic(errors.Wrap(err, "Failed to get absolute YAML path").Error()) - } - ioutil.WriteFile(yamlPath, []byte{}, 0644) - - // execute - if err := migrateToV1(ctx); err != nil { - t.Fatal(errors.Wrapf(err, "Failed to migrate").Error()) - } - - // test - if utils.FileExists(yamlPath) { - t.Fatal("YAML archive file has not been deleted") - } - }) - - t.Run("yaml does not exist", func(t *testing.T) { - // set up - testutils.SetupTmp(ctx) - defer testutils.ClearTmp(ctx) - - yamlPath, err := filepath.Abs(filepath.Join(ctx.HomeDir, ".dnote-yaml-archived")) - if err != nil { - panic(errors.Wrap(err, "Failed to get absolute YAML path").Error()) - } - - // execute - if err := migrateToV1(ctx); err != nil { - t.Fatal(errors.Wrapf(err, "Failed to migrate").Error()) - } - - // test - if utils.FileExists(yamlPath) { - t.Fatal("YAML archive file must not exist") - } - }) -} - -func TestMigrateToV2(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/2-pre-dnote.json", "dnote") - defer testutils.ClearTmp(ctx) - - // execute - if err := migrateToV2(ctx); err != nil { - t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) - } - - // test - b := testutils.ReadFile(ctx, "dnote") - - var postDnote migrateToV2PostDnote - if err := json.Unmarshal(b, &postDnote); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) - } - - for _, book := range postDnote { - testutils.AssertNotEqual(t, book.Name, "", "Book name was not populated") - - for _, note := range book.Notes { - if len(note.UUID) == 8 { - t.Errorf("Note UUID was not migrated. It has length of %d", len(note.UUID)) - } - - testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over") - testutils.AssertEqual(t, note.EditedOn, int64(0), "EditedOn was not created properly") - } - } -} - -func TestMigrateToV3(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/3-pre-dnote.json", "dnote") - defer testutils.ClearTmp(ctx) - - // execute - if err := migrateToV3(ctx); err != nil { - t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) - } - - // test - b := testutils.ReadFile(ctx, "dnote") - var postDnote migrateToV3Dnote - if err := json.Unmarshal(b, &postDnote); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) - } - - b = testutils.ReadFile(ctx, "actions") - var actions []migrateToV3Action - if err := json.Unmarshal(b, &actions); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the actions").Error()) - } - - testutils.AssertEqual(t, len(actions), 6, "actions length mismatch") - - for _, book := range postDnote { - for _, note := range book.Notes { - testutils.AssertNotEqual(t, note.AddedOn, int64(0), "AddedOn was not carried over") - } - } -} - -func TestMigrateToV4(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/4-pre-dnoterc.yaml", "dnoterc") - defer testutils.ClearTmp(ctx) - defer os.Setenv("EDITOR", "") - - // execute - os.Setenv("EDITOR", "vim") - if err := migrateToV4(ctx); err != nil { - t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) - } - - // test - b := testutils.ReadFile(ctx, "dnoterc") - var config migrateToV4PostConfig - if err := yaml.Unmarshal(b, &config); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) - } - - testutils.AssertEqual(t, config.APIKey, "Oev6e1082ORasdf9rjkfjkasdfjhgei", "api key mismatch") - testutils.AssertEqual(t, config.Editor, "vim", "editor mismatch") -} - -func TestMigrateToV5(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/5-pre-actions.json", "actions") - defer testutils.ClearTmp(ctx) - - // execute - if err := migrateToV5(ctx); err != nil { - t.Fatal(errors.Wrap(err, "migrating").Error()) - } - - // test - var oldActions []migrateToV5PreAction - testutils.ReadJSON("./fixtures/5-pre-actions.json", &oldActions) - - b := testutils.ReadFile(ctx, "actions") - var migratedActions []migrateToV5PostAction - if err := json.Unmarshal(b, &migratedActions); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling migrated actions").Error()) - } - - if len(oldActions) != len(migratedActions) { - t.Fatalf("There were %d actions but after migration there were %d", len(oldActions), len(migratedActions)) - } - - for idx := range migratedActions { - migrated := migratedActions[idx] - old := oldActions[idx] - - testutils.AssertNotEqual(t, migrated.UUID, "", fmt.Sprintf("uuid mismatch for migrated item with index %d", idx)) - testutils.AssertEqual(t, migrated.Schema, 1, fmt.Sprintf("schema mismatch for migrated item with index %d", idx)) - testutils.AssertEqual(t, migrated.Timestamp, old.Timestamp, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx)) - testutils.AssertEqual(t, migrated.Type, old.Type, fmt.Sprintf("timestamp mismatch for migrated item with index %d", idx)) - - switch migrated.Type { - case migrateToV5ActionAddNote: - var oldData, migratedData migrateToV5AddNoteData - if err := json.Unmarshal(old.Data, &oldData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling old data").Error()) - } - if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling new data").Error()) - } - - testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) - testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx)) - testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) - case migrateToV5ActionRemoveNote: - var oldData, migratedData migrateToV5RemoveNoteData - if err := json.Unmarshal(old.Data, &oldData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling old data").Error()) - } - if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling new data").Error()) - } - - testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) - testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) - case migrateToV5ActionAddBook: - var oldData, migratedData migrateToV5AddBookData - if err := json.Unmarshal(old.Data, &oldData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling old data").Error()) - } - if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling new data").Error()) - } - - testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) - case migrateToV5ActionRemoveBook: - var oldData, migratedData migrateToV5RemoveBookData - if err := json.Unmarshal(old.Data, &oldData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling old data").Error()) - } - if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling new data").Error()) - } - - testutils.AssertEqual(t, oldData.BookName, migratedData.BookName, fmt.Sprintf("data book_name mismatch for item idx %d", idx)) - case migrateToV5ActionEditNote: - var oldData migrateToV5PreEditNoteData - var migratedData migrateToV5PostEditNoteData - if err := json.Unmarshal(old.Data, &oldData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling old data").Error()) - } - if err := json.Unmarshal(migrated.Data, &migratedData); err != nil { - t.Fatal(errors.Wrap(err, "unmarhsalling new data").Error()) - } - - testutils.AssertEqual(t, oldData.NoteUUID, migratedData.NoteUUID, fmt.Sprintf("data note_uuid mismatch for item idx %d", idx)) - testutils.AssertEqual(t, oldData.Content, migratedData.Content, fmt.Sprintf("data content mismatch for item idx %d", idx)) - testutils.AssertEqual(t, oldData.BookName, migratedData.FromBook, "book_name should have been renamed to from_book") - testutils.AssertEqual(t, migratedData.ToBook, "", "to_book should be empty") - } - } -} - -func TestMigrateToV6(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/6-pre-dnote.json", "dnote") - defer testutils.ClearTmp(ctx) - - // execute - if err := migrateToV6(ctx); err != nil { - t.Fatal(errors.Wrap(err, "Failed to migrate").Error()) - } - - // test - b := testutils.ReadFile(ctx, "dnote") - var got migrateToV6PostDnote - if err := json.Unmarshal(b, &got); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) - } - - b = testutils.ReadFileAbs("./fixtures/6-post-dnote.json") - var expected migrateToV6PostDnote - if err := json.Unmarshal(b, &expected); err != nil { - t.Fatal(errors.Wrap(err, "Failed to unmarshal the result into Dnote").Error()) - } - - if ok := reflect.DeepEqual(expected, got); !ok { - t.Errorf("Payload does not match.\nActual: %+v\nExpected: %+v", got, expected) - } -} - -func TestMigrateToV7(t *testing.T) { - ctx := testutils.InitCtx("../tmp") - - // set up - testutils.SetupTmp(ctx) - testutils.WriteFile(ctx, "./fixtures/7-pre-actions.json", "actions") - defer testutils.ClearTmp(ctx) - - // execute - if err := migrateToV7(ctx); err != nil { - t.Fatal(errors.Wrap(err, "migrating").Error()) - } - - // test - b := testutils.ReadFile(ctx, "actions") - var got []migrateToV7Action - if err := json.Unmarshal(b, &got); err != nil { - t.Fatal(errors.Wrap(err, "unmarshalling the result").Error()) - } - - b2 := testutils.ReadFileAbs("./fixtures/7-post-actions.json") - var expected []migrateToV7Action - if err := json.Unmarshal(b, &expected); err != nil { - t.Fatal(errors.Wrap(err, "unmarshalling the result into Dnote").Error()) - } - - ok, err := testutils.IsEqualJSON(b, b2) - if err != nil { - t.Fatal(errors.Wrap(err, "comparing JSON").Error()) - } - - if !ok { - t.Errorf("Result does not match.\nActual: %+v\nExpected: %+v", got, expected) - } } diff --git a/migrate/migrations.go b/migrate/migrations.go deleted file mode 100644 index 55ff8a00..00000000 --- a/migrate/migrations.go +++ /dev/null @@ -1,383 +0,0 @@ -package migrate - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" - "time" - - "github.com/dnote/cli/infra" - "github.com/dnote/cli/utils" - "github.com/pkg/errors" - "github.com/satori/go.uuid" - "gopkg.in/yaml.v2" -) - -// migrateToV1 deletes YAML archive if exists -func migrateToV1(ctx infra.DnoteCtx) error { - yamlPath := fmt.Sprintf("%s/%s", ctx.HomeDir, ".dnote-yaml-archived") - if !utils.FileExists(yamlPath) { - return nil - } - - if err := os.Remove(yamlPath); err != nil { - return errors.Wrap(err, "Failed to delete .dnote archive") - } - - return nil -} - -func migrateToV2(ctx infra.DnoteCtx) error { - notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) - - b, err := ioutil.ReadFile(notePath) - if err != nil { - return errors.Wrap(err, "Failed to read the note file") - } - - var preDnote migrateToV2PreDnote - postDnote := migrateToV2PostDnote{} - - err = json.Unmarshal(b, &preDnote) - if err != nil { - return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") - } - - for bookName, book := range preDnote { - var notes = make([]migrateToV2PostNote, 0, len(book)) - for _, note := range book { - newNote := migrateToV2PostNote{ - UUID: uuid.NewV4().String(), - Content: note.Content, - AddedOn: note.AddedOn, - EditedOn: 0, - } - - notes = append(notes, newNote) - } - - b := migrateToV2PostBook{ - Name: bookName, - Notes: notes, - } - - postDnote[bookName] = b - } - - d, err := json.MarshalIndent(postDnote, "", " ") - if err != nil { - return errors.Wrap(err, "Failed to marshal new dnote into JSON") - } - - err = ioutil.WriteFile(notePath, d, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write the new dnote into the file") - } - - return nil -} - -// migrateToV3 generates actions for existing dnote -func migrateToV3(ctx infra.DnoteCtx) error { - notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) - actionsPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) - - b, err := ioutil.ReadFile(notePath) - if err != nil { - return errors.Wrap(err, "Failed to read the note file") - } - - var dnote migrateToV3Dnote - - err = json.Unmarshal(b, &dnote) - if err != nil { - return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") - } - - var actions []migrateToV3Action - - for bookName, book := range dnote { - // Find the minimum added_on timestamp from the notes that belong to the book - // to give timstamp to the add_book action. - // Logically add_book must have happened no later than the first add_note - // to the book in order for sync to work. - minTs := time.Now().Unix() - for _, note := range book.Notes { - if note.AddedOn < minTs { - minTs = note.AddedOn - } - } - - action := migrateToV3Action{ - Type: migrateToV3ActionAddBook, - Data: map[string]interface{}{ - "book_name": bookName, - }, - Timestamp: minTs, - } - actions = append(actions, action) - - for _, note := range book.Notes { - action := migrateToV3Action{ - Type: migrateToV3ActionAddNote, - Data: map[string]interface{}{ - "note_uuid": note.UUID, - "book_name": book.Name, - "content": note.Content, - }, - Timestamp: note.AddedOn, - } - actions = append(actions, action) - } - } - - a, err := json.Marshal(actions) - if err != nil { - return errors.Wrap(err, "Failed to marshal actions into JSON") - } - - err = ioutil.WriteFile(actionsPath, a, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write the actions into a file") - } - - return nil -} - -func getEditorCommand() string { - editor := os.Getenv("EDITOR") - - switch editor { - case "atom": - return "atom -w" - case "subl": - return "subl -n -w" - case "mate": - return "mate -w" - case "vim": - return "vim" - case "nano": - return "nano" - case "emacs": - return "emacs" - default: - return "vi" - } -} - -func migrateToV4(ctx infra.DnoteCtx) error { - configPath := fmt.Sprintf("%s/dnoterc", ctx.DnoteDir) - - b, err := ioutil.ReadFile(configPath) - if err != nil { - return errors.Wrap(err, "Failed to read the config file") - } - - var preConfig migrateToV4PreConfig - err = yaml.Unmarshal(b, &preConfig) - if err != nil { - return errors.Wrap(err, "Failed to unmarshal existing config into JSON") - } - - postConfig := migrateToV4PostConfig{ - APIKey: preConfig.APIKey, - Editor: getEditorCommand(), - } - - data, err := yaml.Marshal(postConfig) - if err != nil { - return errors.Wrap(err, "Failed to marshal config into JSON") - } - - err = ioutil.WriteFile(configPath, data, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write the config into a file") - } - - return nil -} - -// migrateToV5 migrates actions -func migrateToV5(ctx infra.DnoteCtx) error { - actionsPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) - - b, err := ioutil.ReadFile(actionsPath) - if err != nil { - return errors.Wrap(err, "reading the actions file") - } - - var actions []migrateToV5PreAction - err = json.Unmarshal(b, &actions) - if err != nil { - return errors.Wrap(err, "unmarshalling actions to JSON") - } - - result := []migrateToV5PostAction{} - - for _, action := range actions { - var data json.RawMessage - - switch action.Type { - case migrateToV5ActionEditNote: - var oldData migrateToV5PreEditNoteData - if err = json.Unmarshal(action.Data, &oldData); err != nil { - return errors.Wrapf(err, "unmarshalling old data of an edit note action %d", action.ID) - } - - migratedData := migrateToV5PostEditNoteData{ - NoteUUID: oldData.NoteUUID, - FromBook: oldData.BookName, - Content: oldData.Content, - } - b, err = json.Marshal(migratedData) - if err != nil { - return errors.Wrap(err, "marshalling data") - } - - data = b - default: - data = action.Data - } - - migrated := migrateToV5PostAction{ - UUID: uuid.NewV4().String(), - Schema: 1, - Type: action.Type, - Data: data, - Timestamp: action.Timestamp, - } - - result = append(result, migrated) - } - - a, err := json.Marshal(result) - if err != nil { - return errors.Wrap(err, "marshalling result into JSON") - } - err = ioutil.WriteFile(actionsPath, a, 0644) - if err != nil { - return errors.Wrap(err, "writing the result into a file") - } - - return nil -} - -// migrateToV6 adds a 'public' field to notes -func migrateToV6(ctx infra.DnoteCtx) error { - notePath := fmt.Sprintf("%s/dnote", ctx.DnoteDir) - - b, err := ioutil.ReadFile(notePath) - if err != nil { - return errors.Wrap(err, "Failed to read the note file") - } - - var preDnote migrateToV6PreDnote - postDnote := migrateToV6PostDnote{} - - err = json.Unmarshal(b, &preDnote) - if err != nil { - return errors.Wrap(err, "Failed to unmarshal existing dnote into JSON") - } - - for bookName, book := range preDnote { - var notes = make([]migrateToV6PostNote, 0, len(book.Notes)) - public := false - for _, note := range book.Notes { - newNote := migrateToV6PostNote{ - UUID: note.UUID, - Content: note.Content, - AddedOn: note.AddedOn, - EditedOn: note.EditedOn, - Public: &public, - } - - notes = append(notes, newNote) - } - - b := migrateToV6PostBook{ - Name: bookName, - Notes: notes, - } - - postDnote[bookName] = b - } - - d, err := json.MarshalIndent(postDnote, "", " ") - if err != nil { - return errors.Wrap(err, "Failed to marshal new dnote into JSON") - } - - err = ioutil.WriteFile(notePath, d, 0644) - if err != nil { - return errors.Wrap(err, "Failed to write the new dnote into the file") - } - - return nil -} - -// migrateToV7 migrates data of edit_note action to the proper version which is -// EditNoteDataV2. Due to a bug, edit logged actions with schema version '2' -// but with a data of EditNoteDataV1. https://github.com/dnote/cli/issues/107 -func migrateToV7(ctx infra.DnoteCtx) error { - actionPath := fmt.Sprintf("%s/actions", ctx.DnoteDir) - - b, err := ioutil.ReadFile(actionPath) - if err != nil { - return errors.Wrap(err, "reading actions file") - } - - var preActions []migrateToV7Action - postActions := []migrateToV7Action{} - err = json.Unmarshal(b, &preActions) - if err != nil { - return errors.Wrap(err, "unmarhsalling existing actions") - } - - for _, action := range preActions { - var newAction migrateToV7Action - - if action.Type == migrateToV7ActionTypeEditNote { - var oldData migrateToV7EditNoteDataV1 - if e := json.Unmarshal(action.Data, &oldData); e != nil { - return errors.Wrapf(e, "unmarshalling data of action with uuid %s", action.Data) - } - - newData := migrateToV7EditNoteDataV2{ - NoteUUID: oldData.NoteUUID, - FromBook: oldData.FromBook, - ToBook: nil, - Content: &oldData.Content, - Public: nil, - } - d, e := json.Marshal(newData) - if e != nil { - return errors.Wrapf(e, "marshalling new data of action with uuid %s", action.Data) - } - - newAction = migrateToV7Action{ - UUID: action.UUID, - Schema: action.Schema, - Type: action.Type, - Timestamp: action.Timestamp, - Data: d, - } - } else { - newAction = action - } - - postActions = append(postActions, newAction) - } - - d, err := json.Marshal(postActions) - if err != nil { - return errors.Wrap(err, "marshalling new actions") - } - - err = ioutil.WriteFile(actionPath, d, 0644) - if err != nil { - return errors.Wrap(err, "writing new actions to a file") - } - - return nil -} diff --git a/migrate/snapshots.go b/migrate/snapshots.go deleted file mode 100644 index 21cd8092..00000000 --- a/migrate/snapshots.go +++ /dev/null @@ -1,156 +0,0 @@ -package migrate - -import "encoding/json" - -// v2 -type migrateToV2PreNote struct { - UID string - Content string - AddedOn int64 -} -type migrateToV2PostNote struct { - UUID string `json:"uuid"` - Content string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"editd_on"` -} -type migrateToV2PreBook []migrateToV2PreNote -type migrateToV2PostBook struct { - Name string `json:"name"` - Notes []migrateToV2PostNote `json:"notes"` -} -type migrateToV2PreDnote map[string]migrateToV2PreBook -type migrateToV2PostDnote map[string]migrateToV2PostBook - -//v3 -var ( - migrateToV3ActionAddNote = "add_note" - migrateToV3ActionAddBook = "add_book" -) - -type migrateToV3Note struct { - UUID string `json:"uuid"` - Content string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"edited_on"` -} -type migrateToV3Book struct { - UUID string `json:"uuid"` - Name string `json:"name"` - Notes []migrateToV3Note `json:"notes"` -} -type migrateToV3Dnote map[string]migrateToV3Book -type migrateToV3Action struct { - Type string `json:"type"` - Data map[string]interface{} `json:"data"` - Timestamp int64 `json:"timestamp"` -} - -// v4 -type migrateToV4PreConfig struct { - Book string - APIKey string -} -type migrateToV4PostConfig struct { - Editor string - APIKey string -} - -// v5 -type migrateToV5AddNoteData struct { - NoteUUID string `json:"note_uuid"` - BookName string `json:"book_name"` - Content string `json:"content"` -} -type migrateToV5RemoveNoteData struct { - NoteUUID string `json:"note_uuid"` - BookName string `json:"book_name"` -} -type migrateToV5AddBookData struct { - BookName string `json:"book_name"` -} -type migrateToV5RemoveBookData struct { - BookName string `json:"book_name"` -} -type migrateToV5PreEditNoteData struct { - NoteUUID string `json:"note_uuid"` - BookName string `json:"book_name"` - Content string `json:"content"` -} -type migrateToV5PostEditNoteData struct { - NoteUUID string `json:"note_uuid"` - FromBook string `json:"from_book"` - ToBook string `json:"to_book"` - Content string `json:"content"` -} -type migrateToV5PreAction struct { - ID int `json:"id"` - Type string `json:"type"` - Data json.RawMessage `json:"data"` - Timestamp int64 `json:"timestamp"` -} -type migrateToV5PostAction struct { - UUID string `json:"uuid"` - Schema int `json:"schema"` - Type string `json:"type"` - Data json.RawMessage `json:"data"` - Timestamp int64 `json:"timestamp"` -} - -var ( - migrateToV5ActionAddNote = "add_note" - migrateToV5ActionRemoveNote = "remove_note" - migrateToV5ActionEditNote = "edit_note" - migrateToV5ActionAddBook = "add_book" - migrateToV5ActionRemoveBook = "remove_book" -) - -// v6 -type migrateToV6PreNote struct { - UUID string `json:"uuid"` - Content string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"edited_on"` -} -type migrateToV6PostNote struct { - UUID string `json:"uuid"` - Content string `json:"content"` - AddedOn int64 `json:"added_on"` - EditedOn int64 `json:"edited_on"` - // Make a pointer to test absent values - Public *bool `json:"public"` -} -type migrateToV6PreBook struct { - Name string `json:"name"` - Notes []migrateToV6PreNote `json:"notes"` -} -type migrateToV6PostBook struct { - Name string `json:"name"` - Notes []migrateToV6PostNote `json:"notes"` -} -type migrateToV6PreDnote map[string]migrateToV6PreBook -type migrateToV6PostDnote map[string]migrateToV6PostBook - -// v7 -var migrateToV7ActionTypeEditNote = "edit_note" - -type migrateToV7Action struct { - UUID string `json:"uuid"` - Schema int `json:"schema"` - Type string `json:"type"` - Data json.RawMessage `json:"data"` - Timestamp int64 `json:"timestamp"` -} -type migrateToV7EditNoteDataV1 struct { - NoteUUID string `json:"note_uuid"` - FromBook string `json:"from_book"` - ToBook string `json:"to_book"` - Content string `json:"content"` -} -type migrateToV7EditNoteDataV2 struct { - NoteUUID string `json:"note_uuid"` - FromBook string `json:"from_book"` - ToBook *string `json:"to_book"` - Content *string `json:"content"` - Public *bool `json:"public"` -} diff --git a/migrate/sql.go b/migrate/sql.go new file mode 100644 index 00000000..278a35f0 --- /dev/null +++ b/migrate/sql.go @@ -0,0 +1 @@ +package migrate diff --git a/scripts/dev.sh b/scripts/dev.sh index 0902a20e..41dbbcbc 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -3,5 +3,5 @@ # dev.sh builds a new binary and replaces the old one in the PATH with it rm "$(which dnote)" $GOPATH/bin/cli -make build-snapshot -ln -s $GOPATH/src/github.com/dnote/cli/dist/darwin_amd64/dnote /usr/local/bin/dnote +go install -ldflags "-X main.apiEndpoint=http://127.0.0.1:5000" --tags "darwin" . +ln -s $GOPATH/bin/cli /usr/local/bin/dnote diff --git a/scripts/dump_schema.sh b/scripts/dump_schema.sh new file mode 100755 index 00000000..e3224df9 --- /dev/null +++ b/scripts/dump_schema.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# dump_schema.sh dumps the current system's dnote schema to testutils package +# to be used while setting up tests + +sqlite3 ~/.dnote/dnote.db .schema > ./testutils/fixtures/schema.sql diff --git a/scripts/test.sh b/scripts/test.sh index 72840d73..c39d6e96 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,3 +4,4 @@ # https://stackoverflow.com/questions/23715302/go-how-to-run-tests-for-multiple-packages go test ./... -p 1 + diff --git a/testutils/fixtures/schema.sql b/testutils/fixtures/schema.sql new file mode 100644 index 00000000..1ca2dd6a --- /dev/null +++ b/testutils/fixtures/schema.sql @@ -0,0 +1,32 @@ +CREATE TABLE notes + ( + id integer PRIMARY KEY AUTOINCREMENT, + uuid text NOT NULL, + book_uuid text NOT NULL, + content text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false + ); +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + ); +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE INDEX idx_books_uuid ON books(uuid); +CREATE INDEX idx_notes_id ON notes(id); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/testutils/main.go b/testutils/main.go index 1414376f..afff4c64 100644 --- a/testutils/main.go +++ b/testutils/main.go @@ -2,12 +2,17 @@ package testutils import ( + "bytes" + "database/sql" "encoding/json" "fmt" + "io" "io/ioutil" "os" + "os/exec" "path/filepath" "reflect" + "strings" "testing" "github.com/dnote/cli/infra" @@ -15,21 +20,47 @@ import ( "github.com/pkg/errors" ) -func InitCtx(relPath string) infra.DnoteCtx { +// InitEnv sets up a test env and returns a new dnote context +func InitEnv(relPath string, relFixturePath string) infra.DnoteCtx { path, err := filepath.Abs(relPath) if err != nil { + panic(errors.Wrap(err, "pasrsing path").Error()) + } + + os.Setenv("DNOTE_HOME_DIR", path) + ctx, err := infra.NewCtx("", "") + if err != nil { + panic(errors.Wrap(err, "getting new ctx").Error()) + } + + // set up directory and db + if err := os.MkdirAll(ctx.DnoteDir, 0755); err != nil { panic(err) } - ctx := infra.DnoteCtx{ - HomeDir: path, - DnoteDir: fmt.Sprintf("%s/.dnote", path), + b := ReadFileAbs(relFixturePath) + setupSQL := string(b) + + db := ctx.DB + _, err = db.Exec(setupSQL) + if err != nil { + panic(errors.Wrap(err, "running schema sql").Error()) } return ctx } -func WriteFile(ctx infra.DnoteCtx, fixturePath string, filename string) { +// TeardownEnv cleans up the test env represented by the given context +func TeardownEnv(ctx infra.DnoteCtx) { + ctx.DB.Close() + + if err := os.RemoveAll(ctx.DnoteDir); err != nil { + panic(err) + } +} + +// CopyFixture writes the content of the given fixture to the filename inside the dnote dir +func CopyFixture(ctx infra.DnoteCtx, fixturePath string, filename string) { fp, err := filepath.Abs(fixturePath) if err != nil { panic(err) @@ -45,7 +76,8 @@ func WriteFile(ctx infra.DnoteCtx, fixturePath string, filename string) { } } -func WriteFileWithContent(ctx infra.DnoteCtx, content []byte, filename string) { +// WriteFile writes a file with the given content and filename inside the dnote dir +func WriteFile(ctx infra.DnoteCtx, content []byte, filename string) { dp, err := filepath.Abs(filepath.Join(ctx.DnoteDir, filename)) if err != nil { panic(err) @@ -56,6 +88,7 @@ func WriteFileWithContent(ctx infra.DnoteCtx, content []byte, filename string) { } } +// ReadFile reads the content of the file with the given name in dnote dir func ReadFile(ctx infra.DnoteCtx, filename string) []byte { path := filepath.Join(ctx.DnoteDir, filename) @@ -67,8 +100,10 @@ func ReadFile(ctx infra.DnoteCtx, filename string) []byte { return b } -func ReadFileAbs(filename string) []byte { - fp, err := filepath.Abs(filename) +// ReadFileAbs reads the content of the file with the given file path by resolving +// it as an absolute path +func ReadFileAbs(relpath string) []byte { + fp, err := filepath.Abs(relpath) if err != nil { panic(err) } @@ -81,27 +116,36 @@ func ReadFileAbs(filename string) []byte { return b } -func SetupTmp(ctx infra.DnoteCtx) { - if err := os.MkdirAll(ctx.DnoteDir, 0755); err != nil { - panic(err) - } -} - -func ClearTmp(ctx infra.DnoteCtx) { - if err := os.RemoveAll(ctx.DnoteDir); err != nil { - panic(err) - } -} - -// AssertEqual fails a test if the actual does not match the expected -func AssertEqual(t *testing.T, a interface{}, b interface{}, message string) { +func checkEqual(a interface{}, b interface{}, message string) (bool, string) { if a == b { - return + return true, "" } + + var m string if len(message) == 0 { - message = fmt.Sprintf("%v != %v", a, b) + m = fmt.Sprintf("%v != %v", a, b) + } else { + m = message + } + errorMessage := fmt.Sprintf("%s. Actual: %+v. Expected: %+v.", m, a, b) + + return false, errorMessage +} + +// AssertEqual errors a test if the actual does not match the expected +func AssertEqual(t *testing.T, a interface{}, b interface{}, message string) { + ok, m := checkEqual(a, b, message) + if !ok { + t.Error(m) + } +} + +// AssertEqualf fails a test if the actual does not match the expected +func AssertEqualf(t *testing.T, a interface{}, b interface{}, message string) { + ok, m := checkEqual(a, b, message) + if !ok { + t.Fatal(m) } - t.Errorf("%s. Actual: %+v. Expected: %+v.", message, a, b) } // AssertNotEqual fails a test if the actual matches the expected @@ -153,3 +197,109 @@ func IsEqualJSON(s1, s2 []byte) (bool, error) { return reflect.DeepEqual(o1, o2), nil } + +// MustExec executes the given SQL query and fails a test if an error occurs +func MustExec(t *testing.T, message string, db *sql.DB, query string, args ...interface{}) sql.Result { + result, err := db.Exec(query, args...) + if err != nil { + t.Fatal(errors.Wrap(errors.Wrap(err, "executing sql"), message)) + } + + return result +} + +// MustScan scans the given row and fails a test in case of any errors +func MustScan(t *testing.T, message string, row *sql.Row, args ...interface{}) { + err := row.Scan(args...) + if err != nil { + t.Fatal(errors.Wrap(errors.Wrap(err, "scanning a row"), message)) + } +} + +// NewDnoteCmd returns a new Dnote command and a pointer to stderr +func NewDnoteCmd(ctx infra.DnoteCtx, binaryName string, arg ...string) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer, error) { + var stderr, stdout bytes.Buffer + + binaryPath, err := filepath.Abs(binaryName) + if err != nil { + return &exec.Cmd{}, &stderr, &stdout, errors.Wrap(err, "getting the absolute path to the test binary") + } + + cmd := exec.Command(binaryPath, arg...) + cmd.Env = []string{fmt.Sprintf("DNOTE_DIR=%s", ctx.DnoteDir), fmt.Sprintf("DNOTE_HOME_DIR=%s", ctx.HomeDir)} + cmd.Stderr = &stderr + cmd.Stdout = &stdout + + return cmd, &stderr, &stdout, nil +} + +// RunDnoteCmd runs a dnote command +func RunDnoteCmd(t *testing.T, ctx infra.DnoteCtx, binaryName string, arg ...string) { + t.Logf("running: %s %s", binaryName, strings.Join(arg, " ")) + + cmd, stderr, stdout, err := NewDnoteCmd(ctx, binaryName, arg...) + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrap(err, "getting command").Error()) + } + + cmd.Env = append(cmd.Env, "DNOTE_DEBUG=1") + + if err := cmd.Run(); err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrapf(err, "running command %s", stderr.String())) + } + + // Print stdout if and only if test fails later + t.Logf("\n%s", stdout) +} + +// WaitDnoteCmd runs a dnote command and waits until the command is exited +func WaitDnoteCmd(t *testing.T, ctx infra.DnoteCtx, runFunc func(io.WriteCloser) error, binaryName string, arg ...string) { + t.Logf("running: %s %s", binaryName, strings.Join(arg, " ")) + + cmd, stderr, stdout, err := NewDnoteCmd(ctx, binaryName, arg...) + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrap(err, "getting command").Error()) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrap(err, "getting stdin %s")) + } + defer stdin.Close() + + // Start the program + err = cmd.Start() + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrap(err, "starting command")) + } + + err = runFunc(stdin) + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrap(err, "running with stdin")) + } + + err = cmd.Wait() + if err != nil { + t.Logf("\n%s", stdout) + t.Fatal(errors.Wrapf(err, "running command %s", stderr.String())) + } + + // Print stdout if and only if test fails later + t.Logf("\n%s", stdout) +} + +// UserConfirm simulates confirmation from the user by writing to stdin +func UserConfirm(stdin io.WriteCloser) error { + // confirm + if _, err := io.WriteString(stdin, "y\n"); err != nil { + return errors.Wrap(err, "confirming deletion") + } + + return nil +} diff --git a/testutils/setup.go b/testutils/setup.go new file mode 100644 index 00000000..f469ded5 --- /dev/null +++ b/testutils/setup.go @@ -0,0 +1,61 @@ +package testutils + +import ( + "github.com/dnote/cli/infra" + "testing" +) + +// Setup1 sets up a dnote env #1 +// dnote4.json +func Setup1(t *testing.T, ctx infra.DnoteCtx) { + db := ctx.DB + + b1UUID := "js-book-uuid" + b2UUID := "linux-book-uuid" + + MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js") + MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "linux") + + MustExec(t, "setting up note 1", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943) +} + +// Setup2 sets up a dnote env #2 +// dnote3.json +func Setup2(t *testing.T, ctx infra.DnoteCtx) { + db := ctx.DB + + b1UUID := "js-book-uuid" + b2UUID := "linux-book-uuid" + + MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js") + MustExec(t, "setting up book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "linux") + + MustExec(t, "setting up note 2", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 1, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "Date object implements mathematical comparisons", 1515199951) + MustExec(t, "setting up note 1", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 2, "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943) + MustExec(t, "setting up note 3", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 3, "3e065d55-6d47-42f2-a6bf-f5844130b2d2", b2UUID, "wc -l to count words", 1515199961) +} + +// Setup3 sets up a dnote env #1 +// dnote1.json +func Setup3(t *testing.T, ctx infra.DnoteCtx) { + db := ctx.DB + + b1UUID := "js-book-uuid" + + MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js") + + MustExec(t, "setting up note 1", db, "INSERT INTO notes (uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?)", "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943) +} + +// Setup4 sets up a dnote env #1 +// dnote2.json +func Setup4(t *testing.T, ctx infra.DnoteCtx) { + db := ctx.DB + + b1UUID := "js-book-uuid" + + MustExec(t, "setting up book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "js") + + MustExec(t, "setting up note 1", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 1, "43827b9a-c2b0-4c06-a290-97991c896653", b1UUID, "Booleans have toString()", 1515199943) + MustExec(t, "setting up note 2", db, "INSERT INTO notes (id, uuid, book_uuid, content, added_on) VALUES (?, ?, ?, ?, ?)", 2, "f0d0fbb7-31ff-45ae-9f0f-4e429c0c797f", b1UUID, "Date object implements mathematical comparisons", 1515199951) +} diff --git a/utils/utils.go b/utils/utils.go index ffdce5f2..2df1d6b0 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -4,35 +4,30 @@ import ( "bufio" "io" "io/ioutil" - "math/rand" "os" "path/filepath" - "time" "github.com/dnote/cli/log" "github.com/pkg/errors" "github.com/satori/go.uuid" ) -func init() { - rand.Seed(time.Now().UnixNano()) -} - -// GenerateUID returns a uid -func GenerateUID() string { +// GenerateUUID returns a uid +func GenerateUUID() string { return uuid.NewV4().String() } -func GetInput() (string, error) { +func getInput() (string, error) { reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { - return "", errors.Wrap(err, "Failed to read stdin") + return "", errors.Wrap(err, "reading stdin") } return input, nil } +// AskConfirmation prompts for user input to confirm a choice func AskConfirmation(question string, optimistic bool) (bool, error) { var choices string if optimistic { @@ -43,7 +38,7 @@ func AskConfirmation(question string, optimistic bool) (bool, error) { log.Printf("%s %s: ", question, choices) - res, err := GetInput() + res, err := getInput() if err != nil { return false, errors.Wrap(err, "Failed to get user input") } @@ -63,52 +58,39 @@ func FileExists(filepath string) bool { return !os.IsNotExist(err) } -func IsDir(path string) (bool, error) { - fileInfo, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - - return false, errors.Wrapf(err, "Failed to check if '%s' is directory", path) - } - - return fileInfo.IsDir(), nil -} - // CopyFile copies a file from the src to dest func CopyFile(src, dest string) error { in, err := os.Open(src) if err != nil { - return errors.Wrap(err, "Failed to open the input file") + return errors.Wrap(err, "opening the input file") } defer in.Close() out, err := os.Create(dest) if err != nil { - return errors.Wrap(err, "Failed to create the output file") + return errors.Wrap(err, "creating the output file") } if _, err = io.Copy(out, in); err != nil { - return errors.Wrap(err, "Failed to copy the file content") + return errors.Wrap(err, "copying the file content") } if err = out.Sync(); err != nil { - return errors.Wrap(err, "Failed to flush the output file to disk") + return errors.Wrap(err, "flushing the output file to disk") } fi, err := os.Stat(src) if err != nil { - return errors.Wrap(err, "Failed to get file info for the input file") + return errors.Wrap(err, "getting the file info for the input file") } if err = os.Chmod(dest, fi.Mode()); err != nil { - return errors.Wrap(err, "Failed to copy permission to the output file") + return errors.Wrap(err, "copying permission to the output file") } // Close the output file if err = out.Close(); err != nil { - return errors.Wrap(err, "Failed to close the output file") + return errors.Wrap(err, "closing the output file") } return nil @@ -122,25 +104,25 @@ func CopyDir(src, dest string) error { fi, err := os.Stat(srcPath) if err != nil { - return errors.Wrap(err, "Failed to get file info for the input") + return errors.Wrap(err, "getting the file info for the input") } if !fi.IsDir() { - return errors.Wrap(err, "Source is not a directory") + return errors.New("source is not a directory") } _, err = os.Stat(dest) if err != nil && !os.IsNotExist(err) { - return errors.Wrap(err, "Failed to look up the destination") + return errors.Wrap(err, "looking up the destination") } err = os.MkdirAll(dest, fi.Mode()) if err != nil { - return errors.Wrap(err, "Failed to create destination") + return errors.Wrap(err, "creating destination") } entries, err := ioutil.ReadDir(src) if err != nil { - return errors.Wrap(err, "Failed to read directory listing for the input") + return errors.Wrap(err, "reading the directory listing for the input") } for _, entry := range entries { @@ -149,11 +131,11 @@ func CopyDir(src, dest string) error { if entry.IsDir() { if err = CopyDir(srcEntryPath, destEntryPath); err != nil { - return errors.Wrapf(err, "Failed to copy %s", entry.Name()) + return errors.Wrapf(err, "copying %s", entry.Name()) } } else { if err = CopyFile(srcEntryPath, destEntryPath); err != nil { - return errors.Wrapf(err, "Failed to copy %s", entry.Name()) + return errors.Wrapf(err, "copying %s", entry.Name()) } } }