From f526124243463eaac1ed397ab8dfe04448cdc468 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Sat, 18 May 2019 16:52:12 +1000 Subject: [PATCH] Simplify view and edit command (#184) * Simplify the view command * Simplify the edit command * Migrate number-only book names * Run migration * Simplify remove * Print note info when adding, editing, or removing * Disallow users from editing or removing already removed notes --- cli/COMMANDS.md | 14 ++-- cli/cmd/add/add.go | 53 +++++++++--- cli/cmd/add/add_test.go | 85 ++++++++++++++++++++ cli/cmd/cat/cat.go | 51 +++--------- cli/cmd/edit/edit.go | 27 ++++--- cli/cmd/remove/remove.go | 40 ++++----- cli/cmd/view/view.go | 10 ++- cli/{log => core}/output.go | 23 ++++-- cli/core/queries.go | 38 +++++++++ cli/main_test.go | 4 +- cli/migrate/fixtures/local-10-pre-schema.sql | 50 ++++++++++++ cli/migrate/migrate.go | 1 + cli/migrate/migrate_test.go | 71 ++++++++++++++++ cli/migrate/migrations.go | 61 ++++++++++++++ cli/testutils/fixtures/schema.sql | 19 +++-- cli/utils/utils.go | 13 +++ 16 files changed, 447 insertions(+), 113 deletions(-) create mode 100644 cli/cmd/add/add_test.go rename cli/{log => core}/output.go (57%) create mode 100644 cli/core/queries.go create mode 100644 cli/migrate/fixtures/local-10-pre-schema.sql diff --git a/cli/COMMANDS.md b/cli/COMMANDS.md index ec26b444..98ef03a7 100644 --- a/cli/COMMANDS.md +++ b/cli/COMMANDS.md @@ -38,7 +38,7 @@ dnote view dnote view golang # See details of a note -dnote view golang 12 +dnote view 12 ``` ## dnote edit @@ -48,11 +48,11 @@ _alias: e_ Edit a note. ```bash -# Launch a text editor to edit a note with the given index. -dnote edit linux 1 +# Launch a text editor to edit a note with the given id. +dnote edit 12 -# Edit a note with the given index in the specified book with a content. -dnote edit linux 1 -c "New Content" +# Edit a note with the given id in the specified book with a content. +dnote edit 12 -c "New Content" ``` ## dnote remove @@ -62,8 +62,8 @@ _alias: d_ Remove either a note or a book. ```bash -# Remove the note with `index` in the specified book. -dnote remove JS 1 +# Remove the note with an id in the specified book. +dnote remove 1 # Remove the book with the `book name`. dnote remove -b JS diff --git a/cli/cmd/add/add.go b/cli/cmd/add/add.go index b9e1f1c2..47990748 100644 --- a/cli/cmd/add/add.go +++ b/cli/cmd/add/add.go @@ -76,14 +76,28 @@ func isReservedName(name string) bool { return false } +// ErrBookNameReserved is an error incidating that the specified book name is reserved +var ErrBookNameReserved = errors.New("The book name is reserved") + +// ErrNumericBookName is an error for book names that only contain numbers +var ErrNumericBookName = errors.New("The book name cannot contain only numbers") + +func validateBookName(name string) error { + if isReservedName(name) { + return ErrBookNameReserved + } + + if utils.IsNumber(name) { + return ErrNumericBookName + } + + return nil +} + func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { bookName := args[0] - if isReservedName(bookName) { - return errors.Errorf("book name '%s' is reserved", bookName) - } - if content == "" { fpath := core.GetDnoteTmpContentPath(ctx) err := core.GetEditorInput(ctx, fpath, &content) @@ -97,13 +111,19 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } ts := time.Now().UnixNano() - err := writeNote(ctx, bookName, content, ts) + noteRowID, err := writeNote(ctx, bookName, content, ts) if err != nil { return errors.Wrap(err, "Failed to write note") } log.Successf("added to %s\n", bookName) - log.PrintContent(content) + + info, err := core.GetNoteInfo(ctx, noteRowID) + if err != nil { + return err + } + + core.PrintNoteInfo(info) if err := core.CheckUpdate(ctx); err != nil { log.Error(errors.Wrap(err, "automatically checking updates").Error()) @@ -113,10 +133,10 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } } -func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) error { +func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) (string, error) { tx, err := ctx.DB.Begin() if err != nil { - return errors.Wrap(err, "beginning a transaction") + return "", errors.Wrap(err, "beginning a transaction") } var bookUUID string @@ -128,10 +148,10 @@ func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) e err = b.Insert(tx) if err != nil { tx.Rollback() - return errors.Wrap(err, "creating the book") + return "", errors.Wrap(err, "creating the book") } } else if err != nil { - return errors.Wrap(err, "finding the book") + return "", errors.Wrap(err, "finding the book") } noteUUID := utils.GenerateUUID() @@ -140,10 +160,19 @@ func writeNote(ctx infra.DnoteCtx, bookLabel string, content string, ts int64) e err = n.Insert(tx) if err != nil { tx.Rollback() - return errors.Wrap(err, "creating the note") + return "", errors.Wrap(err, "creating the note") + } + + var noteRowID string + err = tx.QueryRow(`SELECT notes.rowid + FROM notes + WHERE notes.uuid = ?`, noteUUID). + Scan(¬eRowID) + if err != nil { + return noteRowID, errors.Wrap(err, "getting the note rowid") } tx.Commit() - return nil + return noteRowID, nil } diff --git a/cli/cmd/add/add_test.go b/cli/cmd/add/add_test.go new file mode 100644 index 00000000..0bc9c185 --- /dev/null +++ b/cli/cmd/add/add_test.go @@ -0,0 +1,85 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote CLI. + * + * Dnote CLI is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote CLI is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Dnote CLI. If not, see . + */ + +package add + +import ( + "testing" + + "github.com/dnote/dnote/cli/testutils" +) + +func TestValidateBookName(t *testing.T) { + testCases := []struct { + input string + expected error + }{ + { + input: "javascript", + expected: nil, + }, + { + input: "node.js", + expected: nil, + }, + { + input: "foo bar", + expected: nil, + }, + { + input: "123", + expected: ErrNumericBookName, + }, + { + input: "+123", + expected: nil, + }, + { + input: "-123", + expected: nil, + }, + { + input: "+javascript", + expected: nil, + }, + { + input: "0", + expected: ErrNumericBookName, + }, + { + input: "0333", + expected: ErrNumericBookName, + }, + + // reserved book names + { + input: "trash", + expected: ErrBookNameReserved, + }, + { + input: "conflicts", + expected: ErrBookNameReserved, + }, + } + + for _, tc := range testCases { + actual := validateBookName(tc.input) + + testutils.AssertEqual(t, actual, tc.expected, "result does not match") + } +} diff --git a/cli/cmd/cat/cat.go b/cli/cmd/cat/cat.go index 60ccbd6e..253fa87f 100644 --- a/cli/cmd/cat/cat.go +++ b/cli/cmd/cat/cat.go @@ -19,10 +19,6 @@ package cat import ( - "database/sql" - "fmt" - "time" - "github.com/dnote/dnote/cli/core" "github.com/dnote/dnote/cli/infra" "github.com/dnote/dnote/cli/log" @@ -63,50 +59,25 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { return cmd } -type noteInfo struct { - BookLabel string - UUID string - Content string - AddedOn int64 - EditedOn int64 -} - // NewRun returns a new run function func NewRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { - db := ctx.DB - bookLabel := args[0] - noteRowID := args[1] + var noteRowID string - 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") + if len(args) == 2 { + log.Plain(log.ColorYellow.Sprintf("DEPRECATED: you no longer need to pass book name to the view command. e.g. `dnote view 123`.\n\n")) + + noteRowID = args[1] + } else { + noteRowID = args[0] } - var info noteInfo - err = db.QueryRow(`SELECT books.label, notes.uuid, notes.body, notes.added_on, notes.edited_on - FROM notes - INNER JOIN books ON books.uuid = notes.book_uuid - WHERE notes.rowid = ? AND books.uuid = ?`, noteRowID, 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'", noteRowID, bookLabel) - } else if err != nil { - return errors.Wrap(err, "querying the note") + info, err := core.GetNoteInfo(ctx, noteRowID) + if err != nil { + return err } - log.Infof("book name: %s\n", info.BookLabel) - log.Infof("note uuid: %s\n", info.UUID) - log.Infof("created at: %s\n", time.Unix(0, info.AddedOn).Format("Jan 2, 2006 3:04pm (MST)")) - if info.EditedOn != 0 { - log.Infof("updated at: %s\n", time.Unix(0, info.EditedOn).Format("Jan 2, 2006 3:04pm (MST)")) - } - fmt.Printf("\n------------------------content------------------------\n") - fmt.Printf("%s", info.Content) - fmt.Printf("\n-------------------------------------------------------\n") + core.PrintNoteInfo(info) return nil } diff --git a/cli/cmd/edit/edit.go b/cli/cmd/edit/edit.go index 23665005..575f3f50 100644 --- a/cli/cmd/edit/edit.go +++ b/cli/cmd/edit/edit.go @@ -34,11 +34,11 @@ import ( var newContent string var example = ` - * Edit the note by index in a book - dnote edit js 3 + * Edit the note by its id + dnote edit 3 * Skip the prompt by providing new content directly - dnote edit js 3 -c "new content"` + dnote edit 3 -c "new content"` // NewCmd returns a new edit command func NewCmd(ctx infra.DnoteCtx) *cobra.Command { @@ -58,7 +58,7 @@ func NewCmd(ctx infra.DnoteCtx) *cobra.Command { } func preRun(cmd *cobra.Command, args []string) error { - if len(args) != 2 { + if len(args) > 2 { return errors.New("Incorrect number of argument") } @@ -68,18 +68,21 @@ func preRun(cmd *cobra.Command, args []string) error { 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 noteRowID string + + if len(args) == 2 { + log.Plain(log.ColorYellow.Sprintf("DEPRECATED: you no longer need to pass book name to the view command. e.g. `dnote view 123`.\n\n")) + + noteRowID = args[1] + } else { + noteRowID = args[0] } var noteUUID, oldContent string - err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(¬eUUID, &oldContent) + err := db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND deleted = false", noteRowID).Scan(¬eUUID, &oldContent) if err == sql.ErrNoRows { - return errors.Errorf("note %s not found in the book '%s'", noteRowID, bookLabel) + return errors.Errorf("note %s not found", noteRowID) } else if err != nil { return errors.Wrap(err, "querying the book") } @@ -112,7 +115,7 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { _, err = tx.Exec(`UPDATE notes SET body = ?, edited_on = ?, dirty = ? - WHERE rowid = ? AND book_uuid = ?`, newContent, ts, true, noteRowID, bookUUID) + WHERE rowid = ?`, newContent, ts, true, noteRowID) if err != nil { tx.Rollback() return errors.Wrap(err, "updating the note") diff --git a/cli/cmd/remove/remove.go b/cli/cmd/remove/remove.go index 781279a4..7e6f9d0b 100644 --- a/cli/cmd/remove/remove.go +++ b/cli/cmd/remove/remove.go @@ -19,7 +19,6 @@ package remove import ( - "database/sql" "fmt" "github.com/dnote/dnote/cli/core" @@ -33,8 +32,8 @@ import ( var targetBookName string var example = ` - * Delete a note by its index from a book - dnote delete js 2 + * Delete a note by its id + dnote delete 2 * Delete a book dnote delete -b js` @@ -65,14 +64,18 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { return nil } - if len(args) < 2 { + var noteRowID string + if len(args) == 2 { + log.Plain(log.ColorYellow.Sprintf("DEPRECATED: you no longer need to pass book name to the view command. e.g. `dnote view 123`.\n\n")) + + noteRowID = args[1] + } else if len(args) == 1 { + noteRowID = args[0] + } else { return errors.New("Missing argument") } - targetBook := args[0] - noteRowID := args[1] - - if err := removeNote(ctx, noteRowID, targetBook); err != nil { + if err := removeNote(ctx, noteRowID); err != nil { return errors.Wrap(err, "removing the note") } @@ -80,24 +83,15 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { } } -func removeNote(ctx infra.DnoteCtx, noteRowID, bookLabel string) error { +func removeNote(ctx infra.DnoteCtx, noteRowID string) error { db := ctx.DB - bookUUID, err := core.GetBookUUID(ctx, bookLabel) + noteInfo, err := core.GetNoteInfo(ctx, noteRowID) if err != nil { - return errors.Wrap(err, "finding book uuid") + return err } - var noteUUID, noteContent string - err = db.QueryRow("SELECT uuid, body FROM notes WHERE rowid = ? AND book_uuid = ?", noteRowID, bookUUID).Scan(¬eUUID, ¬eContent) - 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") - } - - // todo: multiline - log.Printf("body: \"%s\"\n", noteContent) + core.PrintNoteInfo(noteInfo) ok, err := utils.AskConfirmation("remove this note?", false) if err != nil { @@ -113,12 +107,12 @@ func removeNote(ctx infra.DnoteCtx, noteRowID, bookLabel string) error { return errors.Wrap(err, "beginning a transaction") } - if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE uuid = ? AND book_uuid = ?", true, true, "", noteUUID, bookUUID); err != nil { + if _, err = tx.Exec("UPDATE notes SET deleted = ?, dirty = ?, body = ? WHERE uuid = ?", true, true, "", noteInfo.UUID); err != nil { return errors.Wrap(err, "removing the note") } tx.Commit() - log.Successf("removed from %s\n", bookLabel) + log.Successf("removed from %s\n", noteInfo.BookLabel) return nil } diff --git a/cli/cmd/view/view.go b/cli/cmd/view/view.go index b8a55caa..6dae78a6 100644 --- a/cli/cmd/view/view.go +++ b/cli/cmd/view/view.go @@ -26,6 +26,7 @@ import ( "github.com/dnote/dnote/cli/cmd/cat" "github.com/dnote/dnote/cli/cmd/ls" + "github.com/dnote/dnote/cli/utils" ) var example = ` @@ -65,9 +66,16 @@ func newRun(ctx infra.DnoteCtx) core.RunEFunc { return func(cmd *cobra.Command, args []string) error { var run core.RunEFunc - if len(args) <= 1 { + if len(args) == 0 { run = ls.NewRun(ctx) + } else if len(args) == 1 { + if utils.IsNumber(args[0]) { + run = cat.NewRun(ctx) + } else { + run = ls.NewRun(ctx) + } } else if len(args) == 2 { + // DEPRECATED: passing book name to view command is deprecated run = cat.NewRun(ctx) } else { return errors.New("Incorrect number of arguments") diff --git a/cli/log/output.go b/cli/core/output.go similarity index 57% rename from cli/log/output.go rename to cli/core/output.go index 3bc0844b..092a0218 100644 --- a/cli/log/output.go +++ b/cli/core/output.go @@ -16,15 +16,26 @@ * along with Dnote CLI. If not, see . */ -package log +package core import ( "fmt" + "time" + + "github.com/dnote/dnote/cli/log" ) -// 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") +// PrintNoteInfo prints a note information +func PrintNoteInfo(info NoteInfo) { + log.Infof("book name: %s\n", info.BookLabel) + log.Infof("created at: %s\n", time.Unix(0, info.AddedOn).Format("Jan 2, 2006 3:04pm (MST)")) + if info.EditedOn != 0 { + log.Infof("updated at: %s\n", time.Unix(0, info.EditedOn).Format("Jan 2, 2006 3:04pm (MST)")) + } + log.Infof("note id: %d\n", info.RowID) + log.Infof("note uuid: %s\n", info.UUID) + + fmt.Printf("\n------------------------content------------------------\n") + fmt.Printf("%s", info.Content) + fmt.Printf("\n-------------------------------------------------------\n") } diff --git a/cli/core/queries.go b/cli/core/queries.go new file mode 100644 index 00000000..66d078cb --- /dev/null +++ b/cli/core/queries.go @@ -0,0 +1,38 @@ +package core + +import ( + "database/sql" + + "github.com/dnote/dnote/cli/infra" + "github.com/pkg/errors" +) + +// NoteInfo is a basic information about a note +type NoteInfo struct { + RowID int + BookLabel string + UUID string + Content string + AddedOn int64 + EditedOn int64 +} + +// GetNoteInfo returns a NoteInfo for the note with the given noteRowID +func GetNoteInfo(ctx infra.DnoteCtx, noteRowID string) (NoteInfo, error) { + var ret NoteInfo + + db := ctx.DB + err := db.QueryRow(`SELECT books.label, notes.uuid, notes.body, notes.added_on, notes.edited_on, notes.rowid + FROM notes + INNER JOIN books ON books.uuid = notes.book_uuid + WHERE notes.rowid = ? AND notes.deleted = false`, noteRowID). + Scan(&ret.BookLabel, &ret.UUID, &ret.Content, &ret.AddedOn, &ret.EditedOn, &ret.RowID) + if err == sql.ErrNoRows { + return ret, errors.Errorf("note %s not found", noteRowID) + } else if err != nil { + return ret, errors.Wrap(err, "querying the note") + } + + return ret, nil + +} diff --git a/cli/main_test.go b/cli/main_test.go index f34a7a78..8d0aeb1d 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -52,7 +52,7 @@ func TestInit(t *testing.T) { testutils.RunDnoteCmd(t, ctx, binaryName) // Test - if !utils.FileExists(fmt.Sprintf("%s", ctx.DnoteDir)) { + if !utils.FileExists(ctx.DnoteDir) { t.Errorf("dnote directory was not initialized") } if !utils.FileExists(fmt.Sprintf("%s/%s", ctx.DnoteDir, core.ConfigFilename)) { @@ -168,7 +168,7 @@ func TestEditNote_BodyFlag(t *testing.T) { testutils.Setup4(t, ctx) // Execute - testutils.RunDnoteCmd(t, ctx, binaryName, "edit", "js", "2", "-c", "foo bar") + testutils.RunDnoteCmd(t, ctx, binaryName, "edit", "2", "-c", "foo bar") // Test db := ctx.DB diff --git a/cli/migrate/fixtures/local-10-pre-schema.sql b/cli/migrate/fixtures/local-10-pre-schema.sql new file mode 100644 index 00000000..a9679e45 --- /dev/null +++ b/cli/migrate/fixtures/local-10-pre-schema.sql @@ -0,0 +1,50 @@ +CREATE TABLE books + ( + uuid text PRIMARY KEY, + label text NOT NULL + , dirty bool DEFAULT false, usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false); +CREATE TABLE system + ( + key string NOT NULL, + value text NOT NULL + ); +CREATE UNIQUE INDEX idx_books_label ON books(label); +CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); +CREATE TABLE IF NOT EXISTS "notes" + ( + uuid text NOT NULL, + book_uuid text NOT NULL, + body text NOT NULL, + added_on integer NOT NULL, + edited_on integer DEFAULT 0, + public bool DEFAULT false, + dirty bool DEFAULT false, + usn int DEFAULT 0 NOT NULL, + deleted bool DEFAULT false + ); +CREATE VIRTUAL TABLE note_fts USING fts5(content=notes, body, tokenize="porter unicode61 categories 'L* N* Co Ps Pe'") +/* note_fts(body) */; +CREATE TABLE IF NOT EXISTS 'note_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE IF NOT EXISTS 'note_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE IF NOT EXISTS 'note_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE IF NOT EXISTS 'note_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; +CREATE TRIGGER notes_after_insert AFTER INSERT ON notes BEGIN + INSERT INTO note_fts(rowid, body) VALUES (new.rowid, new.body); + END; +CREATE TRIGGER notes_after_delete AFTER DELETE ON notes BEGIN + INSERT INTO note_fts(note_fts, rowid, body) VALUES ('delete', old.rowid, old.body); + END; +CREATE TRIGGER notes_after_update AFTER UPDATE ON notes BEGIN + INSERT INTO note_fts(note_fts, rowid, body) VALUES ('delete', old.rowid, old.body); + INSERT INTO note_fts(rowid, body) VALUES (new.rowid, new.body); + END; +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); +CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); +CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/cli/migrate/migrate.go b/cli/migrate/migrate.go index b604dedb..8179d66c 100644 --- a/cli/migrate/migrate.go +++ b/cli/migrate/migrate.go @@ -43,6 +43,7 @@ var LocalSequence = []migration{ lm7, lm8, lm9, + lm10, } // RemoteSequence is a list of remote migrations to be run diff --git a/cli/migrate/migrate_test.go b/cli/migrate/migrate_test.go index 3173d256..28b7686e 100644 --- a/cli/migrate/migrate_test.go +++ b/cli/migrate/migrate_test.go @@ -890,6 +890,77 @@ func TestLocalMigration9(t *testing.T) { testutils.AssertEqual(t, resCount, 1, "noteFtsCount mismatch") } +func TestLocalMigration10(t *testing.T) { + // set up + ctx := testutils.InitEnv(t, "../tmp", "./fixtures/local-10-pre-schema.sql", false) + defer testutils.TeardownEnv(ctx) + + db := ctx.DB + + b1UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 1", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b1UUID, "123") + b2UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 2", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b2UUID, "123 javascript") + b3UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 3", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b3UUID, "foo") + b4UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 4", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b4UUID, "+123") + b5UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 5", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b5UUID, "0123") + b6UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 6", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b6UUID, "javascript 123") + b7UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 7", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b7UUID, "123 (1)") + b8UUID := utils.GenerateUUID() + testutils.MustExec(t, "inserting book 8", db, "INSERT INTO books (uuid, label) VALUES (?, ?)", b8UUID, "5") + + // Execute + tx, err := db.Begin() + if err != nil { + t.Fatal(errors.Wrap(err, "beginning a transaction")) + } + + err = lm10.run(ctx, tx) + if err != nil { + tx.Rollback() + t.Fatal(errors.Wrap(err, "failed to run")) + } + + tx.Commit() + + // Test + + // assert that note_fts was populated with correct values + var b1Label, b2Label, b3Label, b4Label, b5Label, b6Label, b7Label, b8Label string + var b1Dirty, b2Dirty, b3Dirty, b4Dirty, b5Dirty, b6Dirty, b7Dirty, b8Dirty bool + + testutils.MustScan(t, "getting b1", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b1UUID), &b1Label, &b1Dirty) + testutils.MustScan(t, "getting b2", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b2UUID), &b2Label, &b2Dirty) + testutils.MustScan(t, "getting b3", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b3UUID), &b3Label, &b3Dirty) + testutils.MustScan(t, "getting b4", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b4UUID), &b4Label, &b4Dirty) + testutils.MustScan(t, "getting b5", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b5UUID), &b5Label, &b5Dirty) + testutils.MustScan(t, "getting b6", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b6UUID), &b6Label, &b6Dirty) + testutils.MustScan(t, "getting b7", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b7UUID), &b7Label, &b7Dirty) + testutils.MustScan(t, "getting b8", db.QueryRow("SELECT label, dirty FROM books WHERE uuid = ?", b8UUID), &b8Label, &b8Dirty) + + testutils.AssertEqual(t, b1Label, "123 (2)", "b1Label mismatch") + testutils.AssertEqual(t, b1Dirty, true, "b1Dirty mismatch") + testutils.AssertEqual(t, b2Label, "123 javascript", "b2Label mismatch") + testutils.AssertEqual(t, b2Dirty, false, "b2Dirty mismatch") + testutils.AssertEqual(t, b3Label, "foo", "b3Label mismatch") + testutils.AssertEqual(t, b3Dirty, false, "b3Dirty mismatch") + testutils.AssertEqual(t, b4Label, "+123", "b4Label mismatch") + testutils.AssertEqual(t, b4Dirty, false, "b4Dirty mismatch") + testutils.AssertEqual(t, b5Label, "0123 (1)", "b5Label mismatch") + testutils.AssertEqual(t, b5Dirty, true, "b5Dirty mismatch") + testutils.AssertEqual(t, b6Label, "javascript 123", "b6Label mismatch") + testutils.AssertEqual(t, b6Dirty, false, "b6Dirty mismatch") + testutils.AssertEqual(t, b7Label, "123 (1)", "b7Label mismatch") + testutils.AssertEqual(t, b7Dirty, false, "b7Dirty mismatch") + testutils.AssertEqual(t, b8Label, "5 (1)", "b8Label mismatch") + testutils.AssertEqual(t, b8Dirty, true, "b8Dirty mismatch") +} + func TestRemoteMigration1(t *testing.T) { // set up ctx := testutils.InitEnv(t, "../tmp", "./fixtures/remote-1-pre-schema.sql", false) diff --git a/cli/migrate/migrations.go b/cli/migrate/migrations.go index e624e2a4..dd3ecb02 100644 --- a/cli/migrate/migrations.go +++ b/cli/migrate/migrations.go @@ -22,6 +22,7 @@ import ( "database/sql" "encoding/json" "fmt" + "regexp" "github.com/dnote/actions" "github.com/dnote/dnote/cli/client" @@ -403,6 +404,66 @@ var lm9 = migration{ }, } +var lm10 = migration{ + name: "rename-number-only-book", + run: func(ctx infra.DnoteCtx, tx *infra.DB) error { + migrateBook := func(label string) error { + var uuid string + + err := tx.QueryRow("SELECT uuid FROM books WHERE label = ?", label).Scan(&uuid) + if err != nil { + return errors.Wrap(err, "finding uuid") + } + + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s (%d)", label, i) + + var count int + err := tx.QueryRow("SELECT count(*) FROM books WHERE label = ?", candidate).Scan(&count) + if err != nil { + return errors.Wrap(err, "counting candidate") + } + + if count == 0 { + _, err := tx.Exec("UPDATE books SET label = ?, dirty = ? WHERE uuid = ?", candidate, true, uuid) + if err != nil { + return errors.Wrapf(err, "updating book '%s'", label) + } + + break + } + } + + return nil + } + + rows, err := tx.Query("SELECT label FROM books") + defer rows.Close() + if err != nil { + return errors.Wrap(err, "getting labels") + } + + var regexNumber = regexp.MustCompile(`^\d+$`) + + for rows.Next() { + var label string + err := rows.Scan(&label) + if err != nil { + return errors.Wrap(err, "scannign row") + } + + if regexNumber.MatchString(label) { + err = migrateBook(label) + if err != nil { + return errors.Wrapf(err, "migrating book %s", label) + } + } + } + + return nil + }, +} + var rm1 = migration{ name: "sync-book-uuids-from-server", run: func(ctx infra.DnoteCtx, tx *infra.DB) error { diff --git a/cli/testutils/fixtures/schema.sql b/cli/testutils/fixtures/schema.sql index 6723608b..a9679e45 100644 --- a/cli/testutils/fixtures/schema.sql +++ b/cli/testutils/fixtures/schema.sql @@ -10,14 +10,6 @@ CREATE TABLE system ); CREATE UNIQUE INDEX idx_books_label ON books(label); CREATE UNIQUE INDEX idx_books_uuid ON books(uuid); -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 IF NOT EXISTS "notes" ( uuid text NOT NULL, @@ -30,8 +22,7 @@ CREATE TABLE IF NOT EXISTS "notes" usn int DEFAULT 0 NOT NULL, deleted bool DEFAULT false ); -CREATE VIRTUAL TABLE note_fts - USING fts5(content=notes, body, tokenize = "unicode61") +CREATE VIRTUAL TABLE note_fts USING fts5(content=notes, body, tokenize="porter unicode61 categories 'L* N* Co Ps Pe'") /* note_fts(body) */; CREATE TABLE IF NOT EXISTS 'note_fts_data'(id INTEGER PRIMARY KEY, block BLOB); CREATE TABLE IF NOT EXISTS 'note_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; @@ -47,5 +38,13 @@ CREATE TRIGGER notes_after_update AFTER UPDATE ON notes BEGIN INSERT INTO note_fts(note_fts, rowid, body) VALUES ('delete', old.rowid, old.body); INSERT INTO note_fts(rowid, body) VALUES (new.rowid, new.body); END; +CREATE TABLE actions + ( + uuid text PRIMARY KEY, + schema integer NOT NULL, + type text NOT NULL, + data text NOT NULL, + timestamp integer NOT NULL + ); CREATE UNIQUE INDEX idx_notes_uuid ON notes(uuid); CREATE INDEX idx_notes_book_uuid ON notes(book_uuid); diff --git a/cli/utils/utils.go b/cli/utils/utils.go index f808075c..deb48d12 100644 --- a/cli/utils/utils.go +++ b/cli/utils/utils.go @@ -26,6 +26,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "syscall" @@ -248,3 +249,15 @@ func CopyFile(src, dest string) error { return nil } + +// regexNumber is a regex that matches a string that looks like an integer +var regexNumber = regexp.MustCompile(`^\d+$`) + +// IsNumber checks if the given string is in the form of a number +func IsNumber(s string) bool { + if s == "" { + return false + } + + return regexNumber.MatchString(s) +}