commit 9b797902a3b75b080e876cc6024ca7b157860ce9
parent 1fab48c8ae8366012d7606dc41cc3e5d0815cf2b
Author: Sean Enck <sean@ttypty.com>
Date: Tue, 11 Jul 2023 18:37:31 -0400
move supports globbing
Diffstat:
9 files changed, 199 insertions(+), 18 deletions(-)
diff --git a/internal/app/move.go b/internal/app/move.go
@@ -2,10 +2,20 @@ package app
import (
"errors"
+ "fmt"
"github.com/enckse/lockbox/internal/backend"
)
+type (
+ moveRequest struct {
+ cmd CommandOptions
+ src string
+ dst string
+ overwrite bool
+ }
+)
+
// Move is the CLI command to move entries
func Move(cmd CommandOptions) error {
args := cmd.Args()
@@ -15,21 +25,73 @@ func Move(cmd CommandOptions) error {
t := cmd.Transaction()
src := args[0]
dst := args[1]
- srcExists, err := t.Get(src, backend.SecretValue)
+ m, err := t.MatchPath(src)
+ if err != nil {
+ return err
+ }
+ var requests []moveRequest
+ switch len(m) {
+ case 1:
+ requests = append(requests, moveRequest{cmd: cmd, src: m[0].Path, dst: dst, overwrite: true})
+ case 0:
+ break
+ default:
+ if !backend.IsDirectory(dst) {
+ return fmt.Errorf("%s must be a path, not an entry", dst)
+ }
+ dir := backend.Directory(dst)
+ for _, e := range m {
+ r := moveRequest{cmd: cmd, src: e.Path, dst: backend.NewPath(dir, backend.Base(e.Path)), overwrite: false}
+ if err := r.do(true, true); err != nil {
+ return err
+ }
+ requests = append(requests, r)
+ }
+ }
+ rCount := len(requests)
+ if rCount == 0 {
+ return errors.New("no source entries matched")
+ }
+ for _, r := range requests {
+ if err := r.do(false, rCount == 1); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r moveRequest) do(dryRun, useCommandTransaction bool) error {
+ tx := r.cmd.Transaction()
+ if !dryRun {
+ use, err := backend.NewTransaction()
+ if err != nil {
+ return err
+ }
+ tx = use
+
+ }
+ srcExists, err := tx.Get(r.src, backend.SecretValue)
if err != nil {
return errors.New("unable to get source entry")
}
if srcExists == nil {
return errors.New("no source object found")
}
- dstExists, err := t.Get(dst, backend.BlankValue)
+ dstExists, err := tx.Get(r.dst, backend.BlankValue)
if err != nil {
return errors.New("unable to get destination object")
}
if dstExists != nil {
- if !cmd.Confirm("overwrite destination") {
- return nil
+ if r.overwrite {
+ if !r.cmd.Confirm("overwrite destination") {
+ return nil
+ }
+ } else {
+ return errors.New("unable to overwrite entries when moving multiple items")
}
}
- return t.Move(*srcExists, dst)
+ if dryRun {
+ return nil
+ }
+ return tx.Move(*srcExists, r.dst)
}
diff --git a/internal/app/move_test.go b/internal/app/move_test.go
@@ -22,7 +22,11 @@ type (
func newMockCommand(t *testing.T) *mockCommand {
setup(t)
fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass")
+ fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test2"), "pass")
fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), "pass")
+ fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test1"), "pass")
+ fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test2"), "pass")
+ fullSetup(t, true).Insert(backend.NewPath("test", "test4", "test5"), "pass")
return &mockCommand{t: t, confirmed: false, confirm: true}
}
@@ -48,8 +52,8 @@ func TestMove(t *testing.T) {
if err := app.Move(m); err.Error() != "src/dst required for move" {
t.Errorf("invalid error: %v", err)
}
- m.args = []string{"a", "b"}
- if err := app.Move(m); err.Error() != "unable to get source entry" {
+ m.args = []string{"a/c", "b"}
+ if err := app.Move(m); err.Error() != "no source entries matched" {
t.Errorf("invalid error: %v", err)
}
m.confirmed = false
@@ -60,4 +64,16 @@ func TestMove(t *testing.T) {
if !m.confirmed {
t.Error("no move")
}
+ m.args = []string{"test/test3/*", "test/test2/test3"}
+ if err := app.Move(m); err.Error() != "test/test2/test3 must be a path, not an entry" {
+ t.Errorf("invalid error: %v", err)
+ }
+ m.args = []string{"test/test3/*", "test/test2/"}
+ if err := app.Move(m); err.Error() != "unable to overwrite entries when moving multiple items" {
+ t.Errorf("invalid error: %v", err)
+ }
+ m.args = []string{"test/test3/*", "test/test4/"}
+ if err := app.Move(m); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
}
diff --git a/internal/backend/core.go b/internal/backend/core.go
@@ -54,8 +54,8 @@ func splitComponents(path string) ([]string, string, error) {
if strings.Contains(path, pathSep+pathSep) {
return nil, "", errors.New("unwilling to operate on path with empty segment")
}
- title := base(path)
- parts := strings.Split(directory(path), pathSep)
+ title := Base(path)
+ parts := strings.Split(Directory(path), pathSep)
return parts, title, nil
}
diff --git a/internal/backend/query.go b/internal/backend/query.go
@@ -175,15 +175,20 @@ func NewPath(segments ...string) string {
// Directory gets the offset location of the entry without the 'name'
func (e QueryEntity) Directory() string {
- return directory(e.Path)
+ return Directory(e.Path)
}
-func base(s string) string {
+// Base will get the base name of input path
+func Base(s string) string {
parts := strings.Split(s, pathSep)
+ if len(parts) == 0 {
+ return s
+ }
return parts[len(parts)-1]
}
-func directory(s string) string {
+// Directory will get the directory/group for the given path
+func Directory(s string) string {
parts := strings.Split(s, pathSep)
return NewPath(parts[0 : len(parts)-1]...)
}
@@ -195,3 +200,8 @@ func getValue(e gokeepasslib.Entry, key string) string {
}
return v.Value.Content
}
+
+// IsDirectory will indicate if a path looks like a group/directory
+func IsDirectory(path string) bool {
+ return strings.HasSuffix(path, pathSep)
+}
diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go
@@ -261,3 +261,57 @@ func TestSetModTime(t *testing.T) {
t.Errorf("invalid error: %v", err)
}
}
+
+func TestIsDirectory(t *testing.T) {
+ if backend.IsDirectory("") {
+ t.Error("invalid directory detection")
+ }
+ if !backend.IsDirectory("/") {
+ t.Error("invalid directory detection")
+ }
+ if backend.IsDirectory("/a") {
+ t.Error("invalid directory detection")
+ }
+}
+
+func TestBase(t *testing.T) {
+ b := backend.Base("")
+ if b != "" {
+ t.Error("invalid base")
+ }
+ b = backend.Base("aaa")
+ if b != "aaa" {
+ t.Error("invalid base")
+ }
+ b = backend.Base("aaa/")
+ if b != "" {
+ t.Error("invalid base")
+ }
+ b = backend.Base("aaa/a")
+ if b != "a" {
+ t.Error("invalid base")
+ }
+}
+
+func TestDirectory(t *testing.T) {
+ b := backend.Directory("")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = backend.Directory("/")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = backend.Directory("/a")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = backend.Directory("a")
+ if b != "" {
+ t.Error("invalid directory")
+ }
+ b = backend.Directory("b/a")
+ if b != "b" {
+ t.Error("invalid directory")
+ }
+}
diff --git a/internal/cli/core_test.go b/internal/cli/core_test.go
@@ -14,7 +14,7 @@ func TestUsage(t *testing.T) {
t.Errorf("invalid usage, out of date? %d", len(u))
}
u, _ = cli.Usage(true)
- if len(u) != 83 {
+ if len(u) != 92 {
t.Errorf("invalid verbose usage, out of date? %d", len(u))
}
for _, usage := range u {
diff --git a/internal/cli/doc.txt b/internal/cli/doc.txt
@@ -8,15 +8,24 @@ changing it outside of 'lb' usage. If a database not normally used by 'lb' is
to be used by 'lb', try using the various readonly settings to control
interactions.
-[remove]
-The 'rm' command can handle a simplistic glob if it is at the END of the path.
-This allows for bulk-removal of entries at multiple levels. Confirmation will
-still be required for removal (matching entries will be listed)
+[globs]
+The 'rm' and 'mv' command can handle a simplistic glob if it is at the END
+of the path. This allows for bulk-removal of entries at multiple levels.
+Confirmation will still be required for removal (matching entries will be
+listed)
+
+For 'mv' the destination must NOT be an entry but the final destination
+location for all matched entries. Overwriting is not allowed by moving
+via glob.
+
+Examples:
lb rm path/to/leaf/dir/*
lb rm path/to/*
+lb mv path/to/* new/path/
+
[clipboard]
By default clipboard commands are detected via determing the platform and
utilizing default commands to interact with (copy to/paste to) the clipboard.
diff --git a/tests/expected.log b/tests/expected.log
@@ -62,9 +62,27 @@ delete entry? (y/N)
delete entry? (y/N) unable to remove: no entities given
+unable to get destination object
+unable to overwrite entries when moving multiple items
keys/k/one2
keyx/d/e
-delete entry? (y/N)
+move/ma/ka2/zzz
+move/ma/ka3/yyy
+move/ma/ka3/zzz
+move/mac/abc
+move/mac/xyz
+move/mac/yyy
+move/mac/zzz
+selected entities:
+ move/ma/ka2/zzz
+ move/ma/ka3/yyy
+ move/ma/ka3/zzz
+ move/mac/abc
+ move/mac/xyz
+ move/mac/yyy
+ move/mac/zzz
+
+delete entries? (y/N) delete entry? (y/N)
keys/k/one2
pre insert keys/k2/t2/one
CALLED
diff --git a/tests/run.sh b/tests/run.sh
@@ -48,8 +48,20 @@ _execute() {
echo y |${LB_BINARY} rm test/k/one
echo
echo
+ echo test2 |${LB_BINARY} insert move/m/ka/abc
+ echo test |${LB_BINARY} insert move/m/ka/xyz
+ echo test2 |${LB_BINARY} insert move/ma/ka/yyy
+ echo test |${LB_BINARY} insert move/ma/ka/zzz
+ echo test |${LB_BINARY} insert move/ma/ka2/zzz
+ echo test |${LB_BINARY} insert move/ma/ka3/yyy
+ echo test |${LB_BINARY} insert move/ma/ka3/zzz
+ ${LB_BINARY} mv move/m/* move/mac/
+ ${LB_BINARY} mv move/ma/ka/* move/mac/
+ ${LB_BINARY} mv move/ma/ka2/* move/mac/
+ ${LB_BINARY} mv move/ma/ka3/* move/mac/
${LB_BINARY} mv key/a/one keyx/d/e
${LB_BINARY} ls
+ echo y |${LB_BINARY} rm move/*
echo y |${LB_BINARY} rm keyx/d/e
echo
${LB_BINARY} ls