mirror of
https://github.com/dnote/dnote
synced 2026-03-14 14:35:50 +01:00
parent
91f35ceffa
commit
0e803c31cc
17 changed files with 112 additions and 4302 deletions
10
.travis.yml
10
.travis.yml
|
|
@ -1,10 +0,0 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- 1.11.x
|
||||
|
||||
install:
|
||||
- curl -L https://github.com/golang/dep/releases/download/v0.3.2/dep-linux-amd64 -o ./dep && chmod +x ./dep
|
||||
- ./dep ensure
|
||||
script:
|
||||
- ./scripts/test.sh
|
||||
130
Gopkg.lock
generated
130
Gopkg.lock
generated
|
|
@ -1,130 +0,0 @@
|
|||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||
|
||||
|
||||
[[projects]]
|
||||
digest = "1:cd0089a5b5d872ac1b772087c7ee0ff2e71de50aa3a51826be64a63963a85287"
|
||||
name = "github.com/dnote/actions"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "60e81aff027d39f4494c5ee5c1db9c3efc015ccf"
|
||||
version = "v0.2.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:e988ed0ca0d81f4d28772760c02ee95084961311291bdfefc1b04617c178b722"
|
||||
name = "github.com/fatih/color"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4"
|
||||
version = "v1.7.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
digest = "1:3c62bfc6435a5a004b8b90b6088463d19c825868cadb0cd77ed675d2bd12864f"
|
||||
name = "github.com/google/go-github"
|
||||
packages = ["github"]
|
||||
pruneopts = ""
|
||||
revision = "dd29b543e14c33e6373773f2c5ea008b29aeac95"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:cea4aa2038169ee558bf507d5ea02c94ca85bcca28a4c7bb99fd59b31e43a686"
|
||||
name = "github.com/google/go-querystring"
|
||||
packages = ["query"]
|
||||
pruneopts = ""
|
||||
revision = "44c6ddd0a2342c386950e880b658017258da92fc"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be"
|
||||
name = "github.com/inconshreveable/mousetrap"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
|
||||
version = "v1.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:9ea83adf8e96d6304f394d40436f2eb44c1dc3250d223b74088cc253a6cd0a1c"
|
||||
name = "github.com/mattn/go-colorable"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
|
||||
version = "v0.0.9"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:3140e04675a6a91d2a20ea9d10bdadf6072085502e6def6768361260aee4b967"
|
||||
name = "github.com/mattn/go-isatty"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c"
|
||||
version = "v0.0.4"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:8bbdb2b3dce59271877770d6fe7dcbb8362438fa7d2e1e1f688e4bf2aac72706"
|
||||
name = "github.com/mattn/go-sqlite3"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "c7c4067b79cc51e6dfdcef5c702e74b1e0fa7c75"
|
||||
version = "v1.10.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:7365acd48986e205ccb8652cc746f09c8b7876030d53710ea6ef7d0bd0dcd7ca"
|
||||
name = "github.com/pkg/errors"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
|
||||
version = "v0.8.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:7f569d906bdd20d906b606415b7d794f798f91a62fcfb6a4daa6d50690fb7a3f"
|
||||
name = "github.com/satori/go.uuid"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
|
||||
version = "v1.2.0"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6"
|
||||
name = "github.com/spf13/cobra"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385"
|
||||
version = "v0.0.3"
|
||||
|
||||
[[projects]]
|
||||
digest = "1:cbaf13cdbfef0e4734ed8a7504f57fe893d471d62a35b982bf6fb3f036449a66"
|
||||
name = "github.com/spf13/pflag"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "298182f68c66c05229eb03ac171abe6e309ee79a"
|
||||
version = "v1.0.3"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
digest = "1:a4bda6e065eb3ccf1e6adeaa863cb416e0ae6b43e613f20a8931d52d19dc20e2"
|
||||
name = "golang.org/x/sys"
|
||||
packages = ["unix"]
|
||||
pruneopts = ""
|
||||
revision = "4497e2df6f9e69048a54498c7affbbec3294ad47"
|
||||
|
||||
[[projects]]
|
||||
branch = "v2"
|
||||
digest = "1:f0620375dd1f6251d9973b5f2596228cc8042e887cd7f827e4220bc1ce8c30e2"
|
||||
name = "gopkg.in/yaml.v2"
|
||||
packages = ["."]
|
||||
pruneopts = ""
|
||||
revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
input-imports = [
|
||||
"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",
|
||||
"gopkg.in/yaml.v2",
|
||||
]
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
# Gopkg.toml example
|
||||
#
|
||||
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||
|
|
@ -48,3 +47,7 @@
|
|||
[[constraint]]
|
||||
name = "github.com/mattn/go-sqlite3"
|
||||
version = "1.10.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/dnote/color"
|
||||
version = "1.7.0"
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -33,32 +33,14 @@ Write technical notes without getting distracted from programming. The reasons a
|
|||
- We forget exponentially unless we write down what we learn and come back.
|
||||
- Ideas cannot be grokked unless we can put them down in clear words.
|
||||
|
||||
## Examples
|
||||
|
||||
- Add a note to a book named `linux`
|
||||
|
||||
```
|
||||
dnote add linux -c "find - recursively walk the directory"
|
||||
```
|
||||
|
||||
- See the notes in a book
|
||||
|
||||
```
|
||||
dnote view linux
|
||||
|
||||
• on book linux
|
||||
(0) find - recursively walk the directory
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Please refer to [commands](/COMMANDS.md).
|
||||
Please refer to .
|
||||
|
||||
## Links
|
||||
|
||||
- [Dnote](https://dnote.io)
|
||||
- [Dnote Cloud](https://dnote.io/pricing)
|
||||
- [Browser Extension](https://github.com/dnote/browser-extension)
|
||||
* [Website](https://dnote.io)
|
||||
* [Making Dnote (blog article)](https://github.com/dnote-io/cli)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package add
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/cli/core"
|
||||
|
|
@ -36,7 +35,7 @@ func preRun(cmd *cobra.Command, args []string) error {
|
|||
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <book>",
|
||||
Short: "Add a note",
|
||||
Short: "Add a new note",
|
||||
Aliases: []string{"a", "n", "new"},
|
||||
Example: example,
|
||||
PreRunE: preRun,
|
||||
|
|
@ -86,9 +85,7 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc {
|
|||
}
|
||||
|
||||
log.Successf("added to %s\n", bookName)
|
||||
fmt.Printf("\n------------------------content------------------------\n")
|
||||
fmt.Printf("%s", content)
|
||||
fmt.Printf("\n-------------------------------------------------------\n")
|
||||
log.PrintContent(content)
|
||||
|
||||
if err := core.CheckUpdate(ctx); err != nil {
|
||||
log.Error(errors.Wrap(err, "automatically checking updates").Error())
|
||||
|
|
|
|||
112
cmd/edit/edit.go
112
cmd/edit/edit.go
|
|
@ -1,112 +0,0 @@
|
|||
package edit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/cli/core"
|
||||
"github.com/dnote/cli/infra"
|
||||
"github.com/dnote/cli/log"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newContent string
|
||||
|
||||
var example = `
|
||||
* Edit the note by index in a book
|
||||
dnote edit js 3
|
||||
|
||||
* 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",
|
||||
Short: "Edit a note or a book",
|
||||
Aliases: []string{"e"},
|
||||
Example: example,
|
||||
PreRunE: preRun,
|
||||
RunE: newRun(ctx),
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVarP(&newContent, "content", "c", "", "The new content for the note")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func preRun(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Incorrect number of argument")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
db := ctx.DB
|
||||
bookLabel := args[0]
|
||||
noteRowID := args[1]
|
||||
|
||||
bookUUID, err := core.GetBookUUID(ctx, bookLabel)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "finding book uuid")
|
||||
}
|
||||
|
||||
var noteUUID, oldContent string
|
||||
err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(¬eUUID, &oldContent)
|
||||
if err == sql.ErrNoRows {
|
||||
return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel)
|
||||
} else if err != nil {
|
||||
return errors.Wrap(err, "querying the book")
|
||||
}
|
||||
|
||||
if newContent == "" {
|
||||
fpath := core.GetDnoteTmpContentPath(ctx)
|
||||
|
||||
e := ioutil.WriteFile(fpath, []byte(oldContent), 0644)
|
||||
if e != nil {
|
||||
return errors.Wrap(e, "preparing tmp content file")
|
||||
}
|
||||
|
||||
e = core.GetEditorInput(ctx, fpath, &newContent)
|
||||
if e != nil {
|
||||
return errors.Wrap(err, "getting editor input")
|
||||
}
|
||||
}
|
||||
|
||||
if oldContent == newContent {
|
||||
return errors.New("Nothing changed")
|
||||
}
|
||||
|
||||
ts := time.Now().UnixNano()
|
||||
newContent = core.SanitizeContent(newContent)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "beginning a transaction")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE notes
|
||||
SET body = ?, edited_on = ?, dirty = ?
|
||||
WHERE rowid = ? AND book_uuid = ?`, newContent, ts, true, noteRowID, bookUUID)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "updating the note")
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
log.Success("edited the note\n")
|
||||
fmt.Printf("\n------------------------content------------------------\n")
|
||||
fmt.Printf("%s", newContent)
|
||||
fmt.Printf("\n-------------------------------------------------------\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ func formatFTSSnippet(s string) (string, error) {
|
|||
buf.Reset()
|
||||
} else if tok.Kind == tokenKindHLEnd {
|
||||
format.WriteString("%s")
|
||||
str := log.SprintfYellow("%s", buf.String())
|
||||
str := log.ColorYellow.Sprintf("%s", buf.String())
|
||||
args = append(args, str)
|
||||
|
||||
buf.Reset()
|
||||
|
|
@ -169,8 +169,8 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc {
|
|||
}
|
||||
|
||||
for _, info := range infos {
|
||||
bookLabel := log.SprintfYellow("(%s)", info.BookLabel)
|
||||
rowid := log.SprintfYellow("(%d)", info.RowID)
|
||||
bookLabel := log.ColorYellow.Sprintf("(%s)", info.BookLabel)
|
||||
rowid := log.ColorYellow.Sprintf("(%d)", info.RowID)
|
||||
|
||||
log.Plainf("%s %s %s\n", bookLabel, rowid, info.Body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc {
|
|||
log.Printf("API key: ")
|
||||
|
||||
var apiKey string
|
||||
if _, err := fmt.Scanln(&apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Scanln(&apiKey)
|
||||
|
||||
if apiKey == "" {
|
||||
return errors.New("Empty API key")
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ func printBooks(ctx infra.DnoteCtx) error {
|
|||
}
|
||||
|
||||
for _, info := range infos {
|
||||
log.Printf("%s %s\n", info.BookLabel, log.SprintfYellow("(%d)", info.NoteCount))
|
||||
log.Printf("%s %s\n", info.BookLabel, log.ColorYellow.Sprintf("(%d)", info.NoteCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -171,9 +171,9 @@ func printNotes(ctx infra.DnoteCtx, bookName string) error {
|
|||
for _, info := range infos {
|
||||
body, isExcerpt := formatBody(info.Body)
|
||||
|
||||
rowid := log.SprintfYellow("(%d)", info.RowID)
|
||||
rowid := log.ColorYellow.Sprintf("(%d)", info.RowID)
|
||||
if isExcerpt {
|
||||
body = fmt.Sprintf("%s %s", body, log.SprintfYellow("[---More---]"))
|
||||
body = fmt.Sprintf("%s %s", body, log.ColorYellow.Sprintf("[---More---]"))
|
||||
}
|
||||
|
||||
log.Plainf("%s %s\n", rowid, body)
|
||||
|
|
|
|||
946
cmd/sync/sync.go
946
cmd/sync/sync.go
|
|
@ -1,946 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/dnote/cli/client"
|
||||
"github.com/dnote/cli/core"
|
||||
"github.com/dnote/cli/infra"
|
||||
"github.com/dnote/cli/log"
|
||||
"github.com/dnote/cli/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
modeInsert = iota
|
||||
modeUpdate
|
||||
)
|
||||
|
||||
var example = `
|
||||
dnote sync`
|
||||
|
||||
var isFullSync bool
|
||||
|
||||
// NewCmd returns a new sync command
|
||||
func NewCmd(ctx infra.DnoteCtx) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Sync data with the server",
|
||||
Example: example,
|
||||
RunE: newRun(ctx),
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.BoolVarP(&isFullSync, "full", "f", false, "perform a full sync instead of incrementally syncing only the changed data.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getLastSyncAt(tx *sql.Tx) (int, error) {
|
||||
var ret int
|
||||
|
||||
err := tx.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastSyncAt).Scan(&ret)
|
||||
if err != nil {
|
||||
return ret, errors.Wrap(err, "querying last sync time")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func getLastMaxUSN(tx *sql.Tx) (int, error) {
|
||||
var ret int
|
||||
|
||||
err := tx.QueryRow("SELECT value FROM system WHERE key = ?", infra.SystemLastMaxUSN).Scan(&ret)
|
||||
if err != nil {
|
||||
return ret, errors.Wrap(err, "querying last user max_usn")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// syncList is an aggregation of resources represented in the sync fragments
|
||||
type syncList struct {
|
||||
Notes map[string]client.SyncFragNote
|
||||
Books map[string]client.SyncFragBook
|
||||
ExpungedNotes map[string]bool
|
||||
ExpungedBooks map[string]bool
|
||||
MaxUSN int
|
||||
MaxCurrentTime int64
|
||||
}
|
||||
|
||||
func (l syncList) getLength() int {
|
||||
return len(l.Notes) + len(l.Books) + len(l.ExpungedNotes) + len(l.ExpungedBooks)
|
||||
}
|
||||
|
||||
func newSyncList(fragments []client.SyncFragment) syncList {
|
||||
notes := map[string]client.SyncFragNote{}
|
||||
books := map[string]client.SyncFragBook{}
|
||||
expungedNotes := map[string]bool{}
|
||||
expungedBooks := map[string]bool{}
|
||||
var maxUSN int
|
||||
var maxCurrentTime int64
|
||||
|
||||
for _, fragment := range fragments {
|
||||
for _, note := range fragment.Notes {
|
||||
notes[note.UUID] = note
|
||||
}
|
||||
for _, book := range fragment.Books {
|
||||
books[book.UUID] = book
|
||||
}
|
||||
for _, uuid := range fragment.ExpungedBooks {
|
||||
expungedBooks[uuid] = true
|
||||
}
|
||||
for _, uuid := range fragment.ExpungedNotes {
|
||||
expungedNotes[uuid] = true
|
||||
}
|
||||
|
||||
if fragment.FragMaxUSN > maxUSN {
|
||||
maxUSN = fragment.FragMaxUSN
|
||||
}
|
||||
if fragment.CurrentTime > maxCurrentTime {
|
||||
maxCurrentTime = fragment.CurrentTime
|
||||
}
|
||||
}
|
||||
|
||||
return syncList{
|
||||
Notes: notes,
|
||||
Books: books,
|
||||
ExpungedNotes: expungedNotes,
|
||||
ExpungedBooks: expungedBooks,
|
||||
MaxUSN: maxUSN,
|
||||
MaxCurrentTime: maxCurrentTime,
|
||||
}
|
||||
}
|
||||
|
||||
// getSyncList gets a list of all sync fragments after the specified usn
|
||||
// and aggregates them into a syncList data structure
|
||||
func getSyncList(ctx infra.DnoteCtx, apiKey string, afterUSN int) (syncList, error) {
|
||||
fragments, err := getSyncFragments(ctx, apiKey, afterUSN)
|
||||
if err != nil {
|
||||
return syncList{}, errors.Wrap(err, "getting sync fragments")
|
||||
}
|
||||
|
||||
ret := newSyncList(fragments)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// getSyncFragments repeatedly gets all sync fragments after the specified usn until there is no more new data
|
||||
// remaining and returns the buffered list
|
||||
func getSyncFragments(ctx infra.DnoteCtx, apiKey string, afterUSN int) ([]client.SyncFragment, error) {
|
||||
var buf []client.SyncFragment
|
||||
|
||||
nextAfterUSN := afterUSN
|
||||
|
||||
for {
|
||||
resp, err := client.GetSyncFragment(ctx, apiKey, nextAfterUSN)
|
||||
if err != nil {
|
||||
return buf, errors.Wrap(err, "getting sync fragment")
|
||||
}
|
||||
|
||||
frag := resp.Fragment
|
||||
buf = append(buf, frag)
|
||||
|
||||
nextAfterUSN = frag.FragMaxUSN
|
||||
|
||||
// if there is no more data, break
|
||||
if nextAfterUSN == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("received sync fragments: %+v\n", buf)
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// resolveLabel resolves a book label conflict by repeatedly appending an increasing integer
|
||||
// to the label until it finds a unique label. It returns the first non-conflicting label.
|
||||
func resolveLabel(tx *sql.Tx, label string) (string, error) {
|
||||
var ret string
|
||||
|
||||
for i := 2; ; i++ {
|
||||
ret = fmt.Sprintf("%s (%d)", label, i)
|
||||
|
||||
var cnt int
|
||||
if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", ret).Scan(&cnt); err != nil {
|
||||
return "", errors.Wrapf(err, "checking availability of label %s", ret)
|
||||
}
|
||||
|
||||
if cnt == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// mergeBook inserts or updates the given book in the local database.
|
||||
// If a book with a duplicate label exists locally, it renames the duplicate by appending a number.
|
||||
func mergeBook(tx *sql.Tx, b client.SyncFragBook, mode int) error {
|
||||
var count int
|
||||
if err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", b.Label).Scan(&count); err != nil {
|
||||
return errors.Wrapf(err, "checking for books with a duplicate label %s", b.Label)
|
||||
}
|
||||
|
||||
// if duplicate exists locally, rename it and mark it dirty
|
||||
if count > 0 {
|
||||
newLabel, err := resolveLabel(tx, b.Label)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting a new book label for conflict resolution")
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE label = ?", newLabel, true, b.Label); err != nil {
|
||||
return errors.Wrap(err, "resolving duplicate book label")
|
||||
}
|
||||
}
|
||||
|
||||
if mode == modeInsert {
|
||||
book := core.NewBook(b.UUID, b.Label, b.USN, false, false)
|
||||
if err := book.Insert(tx); err != nil {
|
||||
return errors.Wrapf(err, "inserting note with uuid %s", b.UUID)
|
||||
}
|
||||
} else if mode == modeUpdate {
|
||||
// TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting
|
||||
if _, err := tx.Exec("UPDATE books SET usn = ?, uuid = ?, label = ?, deleted = ? WHERE uuid = ?",
|
||||
b.USN, b.UUID, b.Label, b.Deleted, b.UUID); err != nil {
|
||||
return errors.Wrapf(err, "updating local book %s", b.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepSyncBook(tx *sql.Tx, b client.SyncFragBook) error {
|
||||
var localUSN int
|
||||
var dirty bool
|
||||
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local book %s", b.UUID)
|
||||
}
|
||||
|
||||
// if book exists in the server and does not exist in the client
|
||||
if err == sql.ErrNoRows {
|
||||
if e := mergeBook(tx, b, modeInsert); e != nil {
|
||||
return errors.Wrapf(e, "resolving book")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if e := mergeBook(tx, b, modeUpdate); e != nil {
|
||||
return errors.Wrapf(e, "resolving book")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeNote(tx *sql.Tx, serverNote client.SyncFragNote, localNote core.Note) error {
|
||||
var bookDeleted bool
|
||||
err := tx.QueryRow("SELECT deleted FROM books WHERE uuid = ?", localNote.BookUUID).Scan(&bookDeleted)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "checking if local book %s is deleted", localNote.BookUUID)
|
||||
}
|
||||
|
||||
// if the book is deleted, noop
|
||||
if bookDeleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if the local copy is deleted, and the it was edited on the server, override with server values and mark it not dirty.
|
||||
if localNote.Deleted {
|
||||
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ?, public = ?, dirty = ? WHERE uuid = ?",
|
||||
serverNote.USN, serverNote.BookUUID, serverNote.Body, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, false, serverNote.UUID); err != nil {
|
||||
return errors.Wrapf(err, "updating local note %s", serverNote.UUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: if the client copy is dirty, perform field-by-field merge and report conflict instead of overwriting
|
||||
if _, err := tx.Exec("UPDATE notes SET usn = ?, book_uuid = ?, body = ?, edited_on = ?, deleted = ?, public = ? WHERE uuid = ?",
|
||||
serverNote.USN, serverNote.BookUUID, serverNote.Body, serverNote.EditedOn, serverNote.Deleted, serverNote.Public, serverNote.UUID); err != nil {
|
||||
return errors.Wrapf(err, "updating local note %s", serverNote.UUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepSyncNote(tx *sql.Tx, n client.SyncFragNote) error {
|
||||
var localNote core.Note
|
||||
err := tx.QueryRow("SELECT usn, book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
|
||||
Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local note %s", n.UUID)
|
||||
}
|
||||
|
||||
// if note exists in the server and does not exist in the client, insert the note.
|
||||
if err == sql.ErrNoRows {
|
||||
note := core.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false)
|
||||
|
||||
if err := note.Insert(tx); err != nil {
|
||||
return errors.Wrapf(err, "inserting note with uuid %s", n.UUID)
|
||||
}
|
||||
} else {
|
||||
if err := mergeNote(tx, n, localNote); err != nil {
|
||||
return errors.Wrap(err, "merging local note")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fullSyncNote(tx *sql.Tx, n client.SyncFragNote) error {
|
||||
var localNote core.Note
|
||||
err := tx.QueryRow("SELECT usn,book_uuid, dirty, deleted FROM notes WHERE uuid = ?", n.UUID).
|
||||
Scan(&localNote.USN, &localNote.BookUUID, &localNote.Dirty, &localNote.Deleted)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local note %s", n.UUID)
|
||||
}
|
||||
|
||||
// if note exists in the server and does not exist in the client, insert the note.
|
||||
if err == sql.ErrNoRows {
|
||||
note := core.NewNote(n.UUID, n.BookUUID, n.Body, n.AddedOn, n.EditedOn, n.USN, n.Public, n.Deleted, false)
|
||||
|
||||
if err := note.Insert(tx); err != nil {
|
||||
return errors.Wrapf(err, "inserting note with uuid %s", n.UUID)
|
||||
}
|
||||
} else if n.USN > localNote.USN {
|
||||
if err := mergeNote(tx, n, localNote); err != nil {
|
||||
return errors.Wrap(err, "merging local note")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDeleteNote(tx *sql.Tx, noteUUID string) error {
|
||||
var localUSN int
|
||||
var dirty bool
|
||||
err := tx.QueryRow("SELECT usn, dirty FROM notes WHERE uuid = ?", noteUUID).Scan(&localUSN, &dirty)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local note %s", noteUUID)
|
||||
}
|
||||
|
||||
// if note does not exist on client, noop
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if local copy is not dirty, delete
|
||||
if !dirty {
|
||||
_, err = tx.Exec("DELETE FROM notes WHERE uuid = ?", noteUUID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "deleting local note %s", noteUUID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNotesPristine checks that none of the notes in the given book are dirty
|
||||
func checkNotesPristine(tx *sql.Tx, bookUUID string) (bool, error) {
|
||||
var count int
|
||||
if err := tx.QueryRow("SELECT count(*) FROM notes WHERE book_uuid = ? AND dirty = ?", bookUUID, true).Scan(&count); err != nil {
|
||||
return false, errors.Wrapf(err, "counting notes that are dirty in book %s", bookUUID)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func syncDeleteBook(tx *sql.Tx, bookUUID string) error {
|
||||
var localUSN int
|
||||
var dirty bool
|
||||
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", bookUUID).Scan(&localUSN, &dirty)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local book %s", bookUUID)
|
||||
}
|
||||
|
||||
// if book does not exist on client, noop
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if local copy is dirty, noop. it will be uploaded to the server later
|
||||
if dirty {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok, err := checkNotesPristine(tx, bookUUID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "checking if any notes are dirty in book")
|
||||
}
|
||||
// if the local book is not pristine, do not delete but mark it as dirty
|
||||
// so that it can be uploaded to the server later and become un-deleted
|
||||
if !ok {
|
||||
_, err = tx.Exec("UPDATE books SET dirty = ? WHERE uuid = ?", true, bookUUID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "marking a book dirty with uuid %s", bookUUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM notes WHERE book_uuid = ?", bookUUID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "deleting local notes of the book %s", bookUUID)
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM books WHERE uuid = ?", bookUUID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "deleting local book %s", bookUUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fullSyncBook(tx *sql.Tx, b client.SyncFragBook) error {
|
||||
var localUSN int
|
||||
var dirty bool
|
||||
err := tx.QueryRow("SELECT usn, dirty FROM books WHERE uuid = ?", b.UUID).Scan(&localUSN, &dirty)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrapf(err, "getting local book %s", b.UUID)
|
||||
}
|
||||
|
||||
// if book exists in the server and does not exist in the client
|
||||
if err == sql.ErrNoRows {
|
||||
if e := mergeBook(tx, b, modeInsert); e != nil {
|
||||
return errors.Wrapf(e, "resolving book")
|
||||
}
|
||||
} else if b.USN > localUSN {
|
||||
if e := mergeBook(tx, b, modeUpdate); e != nil {
|
||||
return errors.Wrapf(e, "resolving book")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNoteInList checks if the given syncList contains the note with the given uuid
|
||||
func checkNoteInList(uuid string, list *syncList) bool {
|
||||
if _, ok := list.Notes[uuid]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := list.ExpungedNotes[uuid]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkBookInList checks if the given syncList contains the book with the given uuid
|
||||
func checkBookInList(uuid string, list *syncList) bool {
|
||||
if _, ok := list.Books[uuid]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := list.ExpungedBooks[uuid]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanLocalNotes deletes from the local database any notes that are in invalid state
|
||||
// judging by the full list of resources in the server. Concretely, the only acceptable
|
||||
// situation in which a local note is not present in the server is if it is new and has not been
|
||||
// uploaded (i.e. dirty and usn is 0). Otherwise, it is a result of some kind of error and should be cleaned.
|
||||
func cleanLocalNotes(tx *sql.Tx, fullList *syncList) error {
|
||||
rows, err := tx.Query("SELECT uuid, usn, dirty FROM notes")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting local notes")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var note core.Note
|
||||
if err := rows.Scan(¬e.UUID, ¬e.USN, ¬e.Dirty); err != nil {
|
||||
return errors.Wrap(err, "scanning a row for local note")
|
||||
}
|
||||
|
||||
ok := checkNoteInList(note.UUID, fullList)
|
||||
if !ok && (!note.Dirty || note.USN != 0) {
|
||||
err = note.Expunge(tx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "expunging a note")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanLocalBooks deletes from the local database any books that are in invalid state
|
||||
func cleanLocalBooks(tx *sql.Tx, fullList *syncList) error {
|
||||
rows, err := tx.Query("SELECT uuid, usn, dirty FROM books")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting local books")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var book core.Book
|
||||
if err := rows.Scan(&book.UUID, &book.USN, &book.Dirty); err != nil {
|
||||
return errors.Wrap(err, "scanning a row for local book")
|
||||
}
|
||||
|
||||
ok := checkBookInList(book.UUID, fullList)
|
||||
if !ok && (!book.Dirty || book.USN != 0) {
|
||||
err = book.Expunge(tx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "expunging a book")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fullSync(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) error {
|
||||
log.Debug("performing a full sync\n")
|
||||
log.Info("resolving delta.")
|
||||
|
||||
list, err := getSyncList(ctx, apiKey, 0)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting sync list")
|
||||
}
|
||||
|
||||
fmt.Printf(" (total %d).", list.getLength())
|
||||
|
||||
// clean resources that are in erroneous states
|
||||
if err := cleanLocalNotes(tx, &list); err != nil {
|
||||
return errors.Wrap(err, "cleaning up local notes")
|
||||
}
|
||||
if err := cleanLocalBooks(tx, &list); err != nil {
|
||||
return errors.Wrap(err, "cleaning up local books")
|
||||
}
|
||||
|
||||
for _, note := range list.Notes {
|
||||
if err := fullSyncNote(tx, note); err != nil {
|
||||
return errors.Wrap(err, "merging note")
|
||||
}
|
||||
}
|
||||
for _, book := range list.Books {
|
||||
if err := fullSyncBook(tx, book); err != nil {
|
||||
return errors.Wrap(err, "merging book")
|
||||
}
|
||||
}
|
||||
|
||||
for noteUUID := range list.ExpungedNotes {
|
||||
if err := syncDeleteNote(tx, noteUUID); err != nil {
|
||||
return errors.Wrap(err, "deleting note")
|
||||
}
|
||||
}
|
||||
for bookUUID := range list.ExpungedBooks {
|
||||
if err := syncDeleteBook(tx, bookUUID); err != nil {
|
||||
return errors.Wrap(err, "deleting book")
|
||||
}
|
||||
}
|
||||
|
||||
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "saving sync state")
|
||||
}
|
||||
|
||||
fmt.Println(" done.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepSync(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string, afterUSN int) error {
|
||||
log.Debug("performing a step sync\n")
|
||||
|
||||
log.Info("resolving delta.")
|
||||
|
||||
list, err := getSyncList(ctx, apiKey, afterUSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting sync list")
|
||||
}
|
||||
|
||||
fmt.Printf(" (total %d).", list.getLength())
|
||||
|
||||
for _, note := range list.Notes {
|
||||
if err := stepSyncNote(tx, note); err != nil {
|
||||
return errors.Wrap(err, "merging note")
|
||||
}
|
||||
}
|
||||
for _, book := range list.Books {
|
||||
if err := stepSyncBook(tx, book); err != nil {
|
||||
return errors.Wrap(err, "merging book")
|
||||
}
|
||||
}
|
||||
|
||||
for noteUUID := range list.ExpungedNotes {
|
||||
if err := syncDeleteNote(tx, noteUUID); err != nil {
|
||||
return errors.Wrap(err, "deleting note")
|
||||
}
|
||||
}
|
||||
for bookUUID := range list.ExpungedBooks {
|
||||
if err := syncDeleteBook(tx, bookUUID); err != nil {
|
||||
return errors.Wrap(err, "deleting book")
|
||||
}
|
||||
}
|
||||
|
||||
err = saveSyncState(tx, list.MaxCurrentTime, list.MaxUSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "saving sync state")
|
||||
}
|
||||
|
||||
fmt.Println(" done.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendBooks(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) {
|
||||
isBehind := false
|
||||
|
||||
rows, err := tx.Query("SELECT uuid, label, usn, deleted FROM books WHERE dirty")
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "getting syncable books")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var book core.Book
|
||||
|
||||
if err = rows.Scan(&book.UUID, &book.Label, &book.USN, &book.Deleted); err != nil {
|
||||
return isBehind, errors.Wrap(err, "scanning a syncable book")
|
||||
}
|
||||
|
||||
log.Debug("sending book %s\n", book.UUID)
|
||||
|
||||
var respUSN int
|
||||
|
||||
// if new, create it in the server, or else, update.
|
||||
if book.USN == 0 {
|
||||
if book.Deleted {
|
||||
err = book.Expunge(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "expunging a book locally")
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
resp, err := client.CreateBook(ctx, apiKey, book.Label)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "creating a book")
|
||||
}
|
||||
|
||||
_, err = tx.Exec("UPDATE notes SET book_uuid = ? WHERE book_uuid = ?", resp.Book.UUID, book.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating book_uuids of notes")
|
||||
}
|
||||
|
||||
book.Dirty = false
|
||||
book.USN = resp.Book.USN
|
||||
err = book.Update(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "marking book dirty")
|
||||
}
|
||||
|
||||
err = book.UpdateUUID(tx, resp.Book.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating book uuid")
|
||||
}
|
||||
|
||||
respUSN = resp.Book.USN
|
||||
}
|
||||
} else {
|
||||
if book.Deleted {
|
||||
resp, err := client.DeleteBook(ctx, apiKey, book.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "deleting a book")
|
||||
}
|
||||
|
||||
err = book.Expunge(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "expunging a book locally")
|
||||
}
|
||||
|
||||
respUSN = resp.Book.USN
|
||||
} else {
|
||||
resp, err := client.UpdateBook(ctx, apiKey, book.Label, book.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating a book")
|
||||
}
|
||||
|
||||
book.Dirty = false
|
||||
book.USN = resp.Book.USN
|
||||
err = book.Update(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "marking book dirty")
|
||||
}
|
||||
|
||||
respUSN = resp.Book.USN
|
||||
}
|
||||
}
|
||||
|
||||
lastMaxUSN, err := getLastMaxUSN(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "getting last max usn")
|
||||
}
|
||||
|
||||
log.Debug("sent book %s. response USN %d. last max usn: %d\n", book.UUID, respUSN, lastMaxUSN)
|
||||
|
||||
if respUSN == lastMaxUSN+1 {
|
||||
err = updateLastMaxUSN(tx, lastMaxUSN+1)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating last max usn")
|
||||
}
|
||||
} else {
|
||||
isBehind = true
|
||||
}
|
||||
}
|
||||
|
||||
return isBehind, nil
|
||||
}
|
||||
|
||||
func sendNotes(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) {
|
||||
isBehind := false
|
||||
|
||||
rows, err := tx.Query("SELECT uuid, book_uuid, body, public, deleted, usn FROM notes WHERE dirty")
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "getting syncable notes")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var note core.Note
|
||||
|
||||
if err = rows.Scan(¬e.UUID, ¬e.BookUUID, ¬e.Body, ¬e.Public, ¬e.Deleted, ¬e.USN); err != nil {
|
||||
return isBehind, errors.Wrap(err, "scanning a syncable note")
|
||||
}
|
||||
|
||||
log.Debug("sending note %s\n", note.UUID)
|
||||
|
||||
var respUSN int
|
||||
|
||||
// if new, create it in the server, or else, update.
|
||||
if note.USN == 0 {
|
||||
if note.Deleted {
|
||||
// if a note was added and deleted locally, simply expunge
|
||||
err = note.Expunge(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "expunging a note locally")
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
resp, err := client.CreateNote(ctx, apiKey, note.BookUUID, note.Body)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "creating a note")
|
||||
}
|
||||
|
||||
note.Dirty = false
|
||||
note.USN = resp.Result.USN
|
||||
err = note.Update(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "marking note dirty")
|
||||
}
|
||||
|
||||
err = note.UpdateUUID(tx, resp.Result.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating note uuid")
|
||||
}
|
||||
|
||||
respUSN = resp.Result.USN
|
||||
}
|
||||
} else {
|
||||
if note.Deleted {
|
||||
resp, err := client.DeleteNote(ctx, apiKey, note.UUID)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "deleting a note")
|
||||
}
|
||||
|
||||
err = note.Expunge(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "expunging a note locally")
|
||||
}
|
||||
|
||||
respUSN = resp.Result.USN
|
||||
} else {
|
||||
resp, err := client.UpdateNote(ctx, apiKey, note.UUID, note.BookUUID, note.Body, note.Public)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating a note")
|
||||
}
|
||||
|
||||
note.Dirty = false
|
||||
note.USN = resp.Result.USN
|
||||
err = note.Update(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "marking note dirty")
|
||||
}
|
||||
|
||||
respUSN = resp.Result.USN
|
||||
}
|
||||
}
|
||||
|
||||
lastMaxUSN, err := getLastMaxUSN(tx)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "getting last max usn")
|
||||
}
|
||||
|
||||
log.Debug("sent note %s. response USN %d. last max usn: %d\n", note.UUID, respUSN, lastMaxUSN)
|
||||
|
||||
if respUSN == lastMaxUSN+1 {
|
||||
err = updateLastMaxUSN(tx, lastMaxUSN+1)
|
||||
if err != nil {
|
||||
return isBehind, errors.Wrap(err, "updating last max usn")
|
||||
}
|
||||
} else {
|
||||
isBehind = true
|
||||
}
|
||||
}
|
||||
|
||||
return isBehind, nil
|
||||
}
|
||||
|
||||
func sendChanges(ctx infra.DnoteCtx, tx *sql.Tx, apiKey string) (bool, error) {
|
||||
log.Info("sending changes.")
|
||||
|
||||
var delta int
|
||||
err := tx.QueryRow("SELECT (SELECT count(*) FROM notes WHERE dirty) + (SELECT count(*) FROM books WHERE dirty)").Scan(&delta)
|
||||
|
||||
fmt.Printf(" (total %d).", delta)
|
||||
|
||||
behind1, err := sendBooks(ctx, tx, apiKey)
|
||||
if err != nil {
|
||||
return behind1, errors.Wrap(err, "sending books")
|
||||
}
|
||||
|
||||
behind2, err := sendNotes(ctx, tx, apiKey)
|
||||
if err != nil {
|
||||
return behind2, errors.Wrap(err, "sending notes")
|
||||
}
|
||||
|
||||
fmt.Println(" done.")
|
||||
|
||||
isBehind := behind1 || behind2
|
||||
|
||||
return isBehind, nil
|
||||
}
|
||||
|
||||
func updateLastMaxUSN(tx *sql.Tx, val int) error {
|
||||
_, err := tx.Exec("UPDATE system SET value = ? WHERE key = ?", val, infra.SystemLastMaxUSN)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "updating %s", infra.SystemLastMaxUSN)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateLastSyncAt(tx *sql.Tx, val int64) error {
|
||||
_, err := tx.Exec("UPDATE system SET value = ? WHERE key = ?", val, infra.SystemLastSyncAt)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "updating %s", infra.SystemLastSyncAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSyncState(tx *sql.Tx, serverTime int64, serverMaxUSN int) error {
|
||||
if err := updateLastMaxUSN(tx, serverMaxUSN); err != nil {
|
||||
return errors.Wrap(err, "updating last max usn")
|
||||
}
|
||||
if err := updateLastSyncAt(tx, serverTime); err != nil {
|
||||
return errors.Wrap(err, "updating last sync at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRun(ctx infra.DnoteCtx) core.RunEFunc {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.ReadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "reading the config")
|
||||
}
|
||||
if config.APIKey == "" {
|
||||
log.Error("login required. please run `dnote login`\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := migrate.Run(ctx, migrate.RemoteSequence, migrate.RemoteMode); err != nil {
|
||||
return errors.Wrap(err, "running remote migrations")
|
||||
}
|
||||
|
||||
db := ctx.DB
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "beginning a transaction")
|
||||
}
|
||||
|
||||
syncState, err := client.GetSyncState(config.APIKey, ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the sync state from the server")
|
||||
}
|
||||
lastSyncAt, err := getLastSyncAt(tx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the last sync time")
|
||||
}
|
||||
lastMaxUSN, err := getLastMaxUSN(tx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the last max_usn")
|
||||
}
|
||||
|
||||
log.Debug("lastSyncAt: %d, lastMaxUSN: %d, syncState: %+v\n", lastSyncAt, lastMaxUSN, syncState)
|
||||
|
||||
var syncErr error
|
||||
if isFullSync || lastSyncAt < syncState.FullSyncBefore {
|
||||
syncErr = fullSync(ctx, tx, config.APIKey)
|
||||
} else if lastMaxUSN != syncState.MaxUSN {
|
||||
syncErr = stepSync(ctx, tx, config.APIKey, lastMaxUSN)
|
||||
} else {
|
||||
// if no need to sync from the server, simply update the last sync timestamp and proceed to send changes
|
||||
err = updateLastSyncAt(tx, syncState.CurrentTime)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "updating last sync at")
|
||||
}
|
||||
}
|
||||
if syncErr != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "syncing changes from the server")
|
||||
}
|
||||
|
||||
isBehind, err := sendChanges(ctx, tx, config.APIKey)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "sending changes")
|
||||
}
|
||||
|
||||
// if server state gets ahead of that of client during the sync, do an additional step sync
|
||||
if isBehind {
|
||||
log.Debug("performing another step sync because client is behind\n")
|
||||
|
||||
updatedLastMaxUSN, err := getLastMaxUSN(tx)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "getting the new last max_usn")
|
||||
}
|
||||
|
||||
err = stepSync(ctx, tx, config.APIKey, updatedLastMaxUSN)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return errors.Wrap(err, "performing the follow-up step sync")
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
log.Success("success\n")
|
||||
|
||||
if err := core.CheckUpdate(ctx); err != nil {
|
||||
log.Error(errors.Wrap(err, "automatically checking updates").Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
22
core/core.go
22
core/core.go
|
|
@ -59,24 +59,28 @@ func GetBookUUID(ctx infra.DnoteCtx, label string) (string, error) {
|
|||
func getEditorCommand() string {
|
||||
editor := os.Getenv("EDITOR")
|
||||
|
||||
var ret string
|
||||
|
||||
switch editor {
|
||||
case "atom":
|
||||
return "atom -w"
|
||||
ret = "atom -w"
|
||||
case "subl":
|
||||
return "subl -n -w"
|
||||
ret = "subl -n -w"
|
||||
case "mate":
|
||||
return "mate -w"
|
||||
ret = "mate -w"
|
||||
case "vim":
|
||||
return "vim"
|
||||
case "nvim":
|
||||
return "nvim"
|
||||
ret = "vim"
|
||||
case "nano":
|
||||
return "nano"
|
||||
ret = "nano"
|
||||
case "emacs":
|
||||
return "emacs"
|
||||
ret = "emacs"
|
||||
case "nvim":
|
||||
ret = "nvim"
|
||||
default:
|
||||
return "vi"
|
||||
ret = "vi"
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// InitFiles creates, if necessary, the dnote directory and files inside
|
||||
|
|
|
|||
47
log/log.go
47
log/log.go
|
|
@ -4,62 +4,77 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/dnote/color"
|
||||
)
|
||||
|
||||
var (
|
||||
SprintfRed = color.New(color.FgRed).SprintfFunc()
|
||||
SprintfGreen = color.New(color.FgGreen).SprintfFunc()
|
||||
SprintfYellow = color.New(color.FgYellow).SprintfFunc()
|
||||
SprintfBlue = color.New(color.FgBlue).SprintfFunc()
|
||||
SprintfGray = color.New(color.FgWhite).SprintfFunc()
|
||||
// ColorRed is a red foreground color
|
||||
ColorRed = color.New(color.FgRed)
|
||||
// ColorGreen is a green foreground color
|
||||
ColorGreen = color.New(color.FgGreen)
|
||||
// ColorYellow is a yellow foreground color
|
||||
ColorYellow = color.New(color.FgYellow)
|
||||
// ColorBlue is a blue foreground color
|
||||
ColorBlue = color.New(color.FgBlue)
|
||||
// ColorGray is a gray foreground color
|
||||
ColorGray = color.New(color.FgWhite)
|
||||
)
|
||||
|
||||
var indent = " "
|
||||
|
||||
// Info prints information
|
||||
func Info(msg string) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfBlue("•"), msg)
|
||||
fmt.Fprintf(color.Output, "%s%s %s\n", indent, ColorBlue.Sprint("•"), msg)
|
||||
}
|
||||
|
||||
// Infof prints information with optional format verbs
|
||||
func Infof(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfBlue("•"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorBlue.Sprint("•"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Success prints a success message
|
||||
func Success(msg string) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfGreen("✔"), msg)
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorGreen.Sprint("✔"), msg)
|
||||
}
|
||||
|
||||
// Successf prints a success message with optional format verbs
|
||||
func Successf(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfGreen("✔"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorGreen.Sprint("✔"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Plain prints a plain message without any prefix symbol
|
||||
func Plain(msg string) {
|
||||
fmt.Printf("%s%s", indent, msg)
|
||||
}
|
||||
|
||||
// Plainf prints a plain message without any prefix symbol. It takes optional format verbs.
|
||||
func Plainf(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s", indent, fmt.Sprintf(msg, v...))
|
||||
fmt.Printf("%s%s", indent, fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Warnf prints a warning message with optional format verbs
|
||||
func Warnf(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfRed("•"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorRed.Sprint("•"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Error prints an error message
|
||||
func Error(msg string) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfRed("⨯"), msg)
|
||||
fmt.Fprintf(color.Output, "%s%s %s\n", indent, ColorRed.Sprint("⨯"), msg)
|
||||
}
|
||||
|
||||
// Errorf prints an error message with optional format verbs
|
||||
func Errorf(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfRed("⨯"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorRed.Sprintf("⨯"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Printf prints an normal message
|
||||
func Printf(msg string, v ...interface{}) {
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, SprintfGray("•"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s%s %s", indent, ColorGray.Sprint("•"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
|
||||
// Debug prints to the console if DNOTE_DEBUG is set
|
||||
func Debug(msg string, v ...interface{}) {
|
||||
if os.Getenv("DNOTE_DEBUG") == "1" {
|
||||
fmt.Fprintf(color.Output, "%s %s", SprintfGray("DEBUG:"), fmt.Sprintf(msg, v...))
|
||||
fmt.Fprintf(color.Output, "%s %s", ColorGray.Sprint("DEBUG:"), fmt.Sprintf(msg, v...))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
log/output.go
Normal file
12
log/output.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PrintContent prints the note content with an appropriate format.
|
||||
func PrintContent(content string) {
|
||||
fmt.Printf("\n-----------------------content-----------------------\n")
|
||||
fmt.Printf("%s", content)
|
||||
fmt.Printf("\n-----------------------------------------------------\n")
|
||||
}
|
||||
|
|
@ -7,12 +7,11 @@ import (
|
|||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/dnote/cli/core"
|
||||
"github.com/dnote/cli/infra"
|
||||
"github.com/dnote/cli/testutils"
|
||||
"github.com/dnote/cli/utils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var binaryName = "test-dnote"
|
||||
|
|
@ -35,7 +34,7 @@ func TestInit(t *testing.T) {
|
|||
testutils.RunDnoteCmd(t, ctx, binaryName)
|
||||
|
||||
// Test
|
||||
if !utils.FileExists(ctx.DnoteDir) {
|
||||
if !utils.FileExists(fmt.Sprintf("%s", ctx.DnoteDir)) {
|
||||
t.Errorf("dnote directory was not initialized")
|
||||
}
|
||||
if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.ConfigFilename)) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package migrate
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/dnote/cli/infra"
|
||||
"github.com/dnote/cli/log"
|
||||
"github.com/pkg/errors"
|
||||
|
|
|
|||
|
|
@ -62,44 +62,6 @@ func FileExists(filepath string) bool {
|
|||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// 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, "opening the input file")
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "creating the output file")
|
||||
}
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return errors.Wrap(err, "copying the file content")
|
||||
}
|
||||
|
||||
if err = out.Sync(); err != nil {
|
||||
return errors.Wrap(err, "flushing the output file to disk")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
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, "copying permission to the output file")
|
||||
}
|
||||
|
||||
// Close the output file
|
||||
if err = out.Close(); err != nil {
|
||||
return errors.Wrap(err, "closing the output file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyDir copies a directory from src to dest, recursively copying nested
|
||||
// directories
|
||||
func CopyDir(src, dest string) error {
|
||||
|
|
@ -167,3 +129,41 @@ func DoAuthorizedReq(ctx infra.DnoteCtx, apiKey, method, path, body string) (*ht
|
|||
|
||||
return res, 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, "opening the input file")
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "creating the output file")
|
||||
}
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return errors.Wrap(err, "copying the file content")
|
||||
}
|
||||
|
||||
if err = out.Sync(); err != nil {
|
||||
return errors.Wrap(err, "flushing the output file to disk")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
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, "copying permission to the output file")
|
||||
}
|
||||
|
||||
// Close the output file
|
||||
if err = out.Close(); err != nil {
|
||||
return errors.Wrap(err, "closing the output file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue