diff --git a/.gitignore b/.gitignore index 849ddff..f4816cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ dist/ +tmp/ diff --git a/cp_test.go b/cp_test.go index b403bd6..1847970 100644 --- a/cp_test.go +++ b/cp_test.go @@ -44,8 +44,9 @@ func TestCopy(t *testing.T) { //nolint counters: map[string]int{ "ma.cp.visited.directories": 1, }, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar", 0755)) + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar", 0755)) + return nil }, }, { @@ -55,14 +56,15 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.directories": 1, "ma.cp.skip.hidden": 2, }, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar", 0755)) - fp, err := runtime(app).Fs.Create("/foo/bar/.something") + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar", 0755)) + fp, err := runtime(c).Fs.Create("/foo/bar/.something") a.NoError(err) a.NoError(fp.Close()) - fp, err = runtime(app).Fs.Create("/foo/bar/.else") + fp, err = runtime(c).Fs.Create("/foo/bar/.else") a.NoError(err) a.NoError(fp.Close()) + return nil }, }, { @@ -72,11 +74,12 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.directories": 1, "ma.cp.skip.unsupported.": 1, }, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar", 0755)) - fp, err := runtime(app).Fs.Create("/foo/bar/something") + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar", 0755)) + fp, err := runtime(c).Fs.Create("/foo/bar/something") a.NoError(err) a.NoError(fp.Close()) + return nil }, }, { @@ -87,14 +90,15 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.skip.unsupported.UKN": 1, "ma.cp.skip.unsupported.txt": 1, }, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar/boo", 0755)) - fp, err := runtime(app).Fs.Create("/foo/bar/DSC18920.UKN") + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar/boo", 0755)) + fp, err := runtime(c).Fs.Create("/foo/bar/DSC18920.UKN") a.NoError(err) a.NoError(fp.Close()) - fp, err = runtime(app).Fs.Create("/foo/bar/schedule.txt") + fp, err = runtime(c).Fs.Create("/foo/bar/schedule.txt") a.NoError(err) a.NoError(fp.Close()) + return nil }, }, { @@ -103,14 +107,15 @@ func TestCopy(t *testing.T) { //nolint counters: map[string]int{ "ma.cp.visited.directories": 1, }, - before: func(app *cli.App) { - image := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + image := createTestFile(t, runtime(c).Fs) a.NoError(image.Close()) // a bit of hack to test reading the entire contents of a .dng file // the exif parser doesn't care about file extensions, it sees only bytes name := image.Name() name = strings.Replace(name, ".jpg", ".dng", 1) - a.NoError(runtime(app).Fs.Rename(image.Name(), name)) + a.NoError(runtime(c).Fs.Rename(image.Name(), name)) + return nil }, }, { @@ -119,20 +124,21 @@ func TestCopy(t *testing.T) { //nolint counters: map[string]int{ "ma.cp.visited.directories": 1, }, - before: func(app *cli.App) { - image := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + image := createTestFile(t, runtime(c).Fs) a.NoError(image.Close()) tm := time.Date(2008, time.March, 15, 11, 22, 0, 0, time.Local) - a.NoError(runtime(app).Fs.Chtimes(image.Name(), tm, tm)) + a.NoError(runtime(c).Fs.Chtimes(image.Name(), tm, tm)) - dst, err := runtime(app).Fs.Stat(image.Name()) + dst, err := runtime(c).Fs.Stat(image.Name()) a.NoError(err) a.NotNil(dst) log.Info().Str("src", image.Name()).Time("dst", dst.ModTime()).Msg("set test modification times") + return nil }, - after: func(app *cli.App) { - dst, err := runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + after: func(c *cli.Context) error { + dst, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") a.NoError(err) a.NotNil(dst) if false { @@ -141,6 +147,7 @@ func TestCopy(t *testing.T) { //nolint log.Info().Time("src", t).Time("dst", dst.ModTime()).Msg("asserting modification times") a.Equalf(t, dst.ModTime(), "expected identical modification times") } + return nil }, }, { @@ -150,13 +157,13 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.directories": 1, "ma.cp.skip.exists": 1, }, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar", 0755)) + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar", 0755)) for _, filename := range []string{"/foo/bar/Nikon_D70.xmp", "/foo/baz/2008/2008-03/15/Nikon_D70.jpg"} { - fp, err := runtime(app).Fs.Create(filename) + fp, err := runtime(c).Fs.Create(filename) a.NoError(err) a.NoError(fp.Close()) - image, err := runtime(app).Fs.Create("/foo/bar/Nikon_D70.jpg") + image, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70.jpg") a.NoError(err) fp, err = os.Open("testdata/Nikon_D70.jpg") a.NoError(err) @@ -166,6 +173,7 @@ func TestCopy(t *testing.T) { //nolint a.NoError(image.Close()) a.NoError(fp.Close()) } + return nil }, }, { @@ -174,20 +182,22 @@ func TestCopy(t *testing.T) { //nolint counters: map[string]int{ "ma.cp.visited.directories": 1, }, - before: func(app *cli.App) { - fp := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) a.NoError(fp.Close()) - fp, err := runtime(app).Fs.Create("/foo/bar/Nikon_D70.xmp") + fp, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70.xmp") a.NoError(err) a.NoError(fp.Close()) + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") a.NoError(err) a.NotNil(stat) - stat, err = runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") + stat, err = runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") a.NoError(err) a.NotNil(stat) + return nil }, }, { @@ -197,20 +207,22 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.directories": 1, "ma.cp.file.dryrun": 2, }, - before: func(app *cli.App) { - fp := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) a.NoError(fp.Close()) - fp, err := runtime(app).Fs.Create("/foo/bar/Nikon_D70.xmp") + fp, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70.xmp") a.NoError(err) a.NoError(fp.Close()) + return nil }, - after: func(app *cli.App) { - _, err := runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + after: func(c *cli.Context) error { + _, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") a.Error(err) a.True(os.IsNotExist(err)) - _, err = runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") + _, err = runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") a.Error(err) a.True(os.IsNotExist(err)) + return nil }, }, { @@ -221,21 +233,23 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.directories": 2, "ma.cp.fileset.skip.unsupported": 1, }, - before: func(app *cli.App) { - fp := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) a.NoError(fp.Close()) - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar/boo", 0755)) - fp, err := runtime(app).Fs.Create("/foo/bar/boo/Nikon_D70.xmp") + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar/boo", 0755)) + fp, err := runtime(c).Fs.Create("/foo/bar/boo/Nikon_D70.xmp") a.NoError(err) a.NoError(fp.Close()) + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") a.NoError(err) a.NotNil(stat) - _, err = runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") + _, err = runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.xmp") a.Error(err) a.True(os.IsNotExist(err)) + return nil }, }, { @@ -248,11 +262,12 @@ func TestCopy(t *testing.T) { //nolint "ma.cp.visited.files": 1, "ma.cp.fileset.failed.exif": 1, }, - before: func(app *cli.App) { - fp, err := runtime(app).Fs.Create("/foo/bar/Nikon_D70.jpg") + before: func(c *cli.Context) error { + fp, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70.jpg") a.NoError(err) a.NoError(copyFile(fp, "testdata/user_cmac.json")) a.NoError(fp.Close()) + return nil }, }, { @@ -262,15 +277,17 @@ func TestCopy(t *testing.T) { //nolint counters: map[string]int{ "ma.cp.visited.directories": 1, }, - before: func(app *cli.App) { - fp := createTestFile(t, runtime(app).Fs) + before: func(c *cli.Context) error { + fp := createTestFile(t, runtime(c).Fs) a.NoError(fp.Close()) - runtime(app).Fs = afero.NewReadOnlyFs(runtime(app).Fs) + runtime(c).Fs = afero.NewReadOnlyFs(runtime(c).Fs) + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/baz/2008/2008-03/15/Nikon_D70.jpg") a.True(os.IsNotExist(err)) a.Nil(stat) + return nil }, }, } @@ -278,7 +295,7 @@ func TestCopy(t *testing.T) { //nolint for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, nil, ma.CommandCopy) + run(t, tt, nil, ma.CommandCopy) }) } } diff --git a/docs/manual.md b/docs/manual.md index 36b221f..3462abe 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -29,6 +29,8 @@ All your media archiving needs! * [new album](#new-album) * [new folder](#new-folder) * [patch](#patch) +* [patch album](#patch-album) +* [patch image](#patch-image) * [up](#up) * [user](#user) * [version](#version) @@ -279,13 +281,46 @@ $ ma new folder [flags] **Description** +patch the metadata for albums and images + + + + +## *patch album* + +**Description** + +patch an album (or albums) + + +**Syntax** + +```sh +$ ma patch album [flags] [, ...] +``` + + +**Flags** + +|Name|Aliases|EnvVars|Description| +|-|-|-|-| +|```force```|```f```||force must be specified to apply the patch| +|```keyword```|||| +|```name```|||| +|```urlname```|||| + + +## *patch image* + +**Description** + patch an image (or images) **Syntax** ```sh -$ ma patch [flags] [, ...] +$ ma patch image [flags] [, ...] ``` @@ -293,7 +328,7 @@ $ ma patch [flags] [, ...] |Name|Aliases|EnvVars|Description| |-|-|-|-| -|```force```|||force must be specified to apply the patch| +|```force```|```f```||force must be specified to apply the patch| |```keyword```|||| |```caption```|||| |```title```|||| diff --git a/export_test.go b/export_test.go index 4606b57..8863edc 100644 --- a/export_test.go +++ b/export_test.go @@ -67,13 +67,15 @@ func TestExport(t *testing.T) { //nolint counters: map[string]int{ "ma.export.download.ok": 1, }, - before: func(app *cli.App) { - runtime(app).Grab = &grab{url: runtime(app).URL} + before: func(c *cli.Context) error { + runtime(c).Grab = &grab{url: runtime(c).URL} + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.NoError(err) a.NotNil(stat) + return nil }, }, { @@ -82,17 +84,19 @@ func TestExport(t *testing.T) { //nolint counters: map[string]int{ "ma.export.download.failed.not_found": 1, }, - before: func(app *cli.App) { - runtime(app).Grab = &grab{ - url: runtime(app).URL, + before: func(c *cli.Context) error { + runtime(c).Grab = &grab{ + url: runtime(c).URL, status: http.StatusNotFound, } + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.Nil(stat) a.Error(err) a.True(os.IsNotExist(err)) + return nil }, }, { @@ -102,17 +106,19 @@ func TestExport(t *testing.T) { //nolint "ma.export.download.failed.internal_server_error": 1, }, err: "download failed", - before: func(app *cli.App) { - runtime(app).Grab = &grab{ - url: runtime(app).URL, + before: func(c *cli.Context) error { + runtime(c).Grab = &grab{ + url: runtime(c).URL, status: http.StatusInternalServerError, } + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.Nil(stat) a.Error(err) a.True(os.IsNotExist(err)) + return nil }, }, { @@ -121,18 +127,20 @@ func TestExport(t *testing.T) { //nolint counters: map[string]int{ "ma.export.download.skipping.exists": 1, }, - before: func(app *cli.App) { - runtime(app).Grab = &grab{url: runtime(app).URL} - fp, err := runtime(app).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + before: func(c *cli.Context) error { + runtime(c).Grab = &grab{url: runtime(c).URL} + fp, err := runtime(c).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.NotNil(fp) a.NoError(err) a.NoError(copyFile(fp, "testdata/Nikon_D70.jpg")) a.NoError(fp.Close()) + return nil }, - after: func(app *cli.App) { - stat, err := runtime(app).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + after: func(c *cli.Context) error { + stat, err := runtime(c).Fs.Stat("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.NoError(err) a.NotNil(stat) + return nil }, }, } @@ -140,7 +148,7 @@ func TestExport(t *testing.T) { //nolint for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandExport) + run(t, tt, mux, ma.CommandExport) }) } } diff --git a/find_test.go b/find_test.go index 0c347f3..6419947 100644 --- a/find_test.go +++ b/find_test.go @@ -33,7 +33,7 @@ func TestFind(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandFind) + run(t, tt, mux, ma.CommandFind) }) } } diff --git a/go.mod b/go.mod index 5666583..5a9c187 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/armon/go-metrics v0.3.9 github.com/bzimmer/httpwares v0.0.4 - github.com/bzimmer/smugmug v0.2.0 + github.com/bzimmer/smugmug v0.3.0 github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect diff --git a/go.sum b/go.sum index 7be34cd..bee2c7b 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/bzimmer/httpwares v0.0.4 h1:ttcM0AtYFpmcyISWVFMDOf2TUUAMSm4m5NUFGTdjJ github.com/bzimmer/httpwares v0.0.4/go.mod h1:7/J2QFL6EDur3v11kij1ZJLK7f2cs9qeNXgT7BTjm8E= github.com/bzimmer/manual v0.1.0 h1:bwjezUGB2iKK0kDUHYIb5IzA4+iR/UNDoBrhZ0+ijW4= github.com/bzimmer/manual v0.1.0/go.mod h1:RftaUkPvNcdXbGj2u3qtXDOeBvJgInJLs3lCjnTknks= -github.com/bzimmer/smugmug v0.2.0 h1:o4sOHHCcwUtJndzB9lbiqtnDXhEh9QV9nx6h4ZJxvZ0= -github.com/bzimmer/smugmug v0.2.0/go.mod h1:taHNvu+xBMf6FC0YBL9I/AIww15/gYGBl4D6iNWvv3k= +github.com/bzimmer/smugmug v0.3.0 h1:/99/oLCrKGi0V7eKgEK1mF3FkrhCNj6rJeEQmI95JeY= +github.com/bzimmer/smugmug v0.3.0/go.mod h1:taHNvu+xBMf6FC0YBL9I/AIww15/gYGBl4D6iNWvv3k= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= diff --git a/ls_test.go b/ls_test.go index 507e9bd..8f7d98e 100644 --- a/ls_test.go +++ b/ls_test.go @@ -98,7 +98,7 @@ func TestList(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandList) + run(t, tt, mux, ma.CommandList) }) } } diff --git a/ma_test.go b/ma_test.go index d6370a5..59ffd08 100644 --- a/ma_test.go +++ b/ma_test.go @@ -37,8 +37,8 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func runtime(app *cli.App) *Runtime { - return app.Metadata[RuntimeKey].(*Runtime) +func runtime(c *cli.Context) *Runtime { + return c.App.Metadata[RuntimeKey].(*Runtime) } func copyFile(w io.Writer, filename string) error { @@ -57,7 +57,7 @@ func (e *encoderBlackhole) Encode(_ interface{}) error { return nil } -func NewTestApp(t *testing.T, name string, cmd *cli.Command, url string, opts ...smugmug.Option) *cli.App { +func NewTestApp(t *testing.T, tt harness, cmd *cli.Command, url string) *cli.App { cfg := metrics.DefaultConfig("ma") cfg.EnableRuntimeMetrics = false cfg.TimerGranularity = time.Second @@ -67,7 +67,9 @@ func NewTestApp(t *testing.T, name string, cmd *cli.Command, url string, opts .. t.Error(err) } - client, err := smugmug.NewClient(append(opts, smugmug.WithBaseURL(url))...) + client, err := smugmug.NewClient( + smugmug.WithBaseURL(url), + smugmug.WithHTTPTracing(zerolog.GlobalLevel() == zerolog.DebugLevel)) if err != nil { t.Error(err) } @@ -82,16 +84,19 @@ func NewTestApp(t *testing.T, name string, cmd *cli.Command, url string, opts .. } return &cli.App{ - Name: name, - HelpName: name, + Name: tt.name, + HelpName: tt.name, After: func(c *cli.Context) error { - t.Log(name) - switch v := runtime(c.App).Fs.(type) { + t.Logf("***** %s *****\n", tt.name) + switch v := runtime(c).Fs.(type) { case *afero.MemMapFs: v.List() default: } - return ma.Stats(c) + if err := ma.Stats(c); err != nil { + return err + } + return counters(t, tt.counters)(c) }, Commands: []*cli.Command{cmd}, Metadata: map[string]interface{}{ @@ -104,37 +109,72 @@ func NewTestApp(t *testing.T, name string, cmd *cli.Command, url string, opts .. } } -func findCounter(app *cli.App, name string) (metrics.SampledValue, error) { - sink := runtime(app).Sink - for i := range sink.Data() { - im := sink.Data()[i] - if sample, ok := im.Counters[name]; ok { - return sample, nil +func counters(t *testing.T, expected map[string]int) cli.AfterFunc { + a := assert.New(t) + return func(c *cli.Context) error { + data := runtime(c).Sink.Data() + for key, value := range expected { + var found bool + Loop: + for i := range data { + if counter, ok := data[i].Counters[key]; ok { + found = true + a.Equalf(value, counter.Count, key) + break Loop + } + } + if !found { + return fmt.Errorf("cannot find sample value for {%s}", key) + } } + return nil } - return metrics.SampledValue{}, fmt.Errorf("cannot find sample value for {%s}", name) } type harness struct { - name, err string - args []string - counters map[string]int - before, after func(app *cli.App) + name, err string + args []string + counters map[string]int + before cli.BeforeFunc + after cli.AfterFunc } -func harnessFunc(t *testing.T, tt harness, mux *http.ServeMux, cmd func() *cli.Command) { +func run(t *testing.T, tt harness, mux *http.ServeMux, cmd func() *cli.Command) { a := assert.New(t) svr := httptest.NewServer(mux) defer svr.Close() - app := NewTestApp(t, tt.name, cmd(), svr.URL, smugmug.WithHTTPTracing(false)) + app := NewTestApp(t, tt, cmd(), svr.URL) if tt.before != nil { - tt.before(app) + f := app.Before + app.Before = func(c *cli.Context) error { + for _, f := range []cli.BeforeFunc{f, tt.before} { + if f != nil { + if err := f(c); err != nil { + return err + } + } + } + return nil + } + } + if tt.after != nil { + f := app.After + app.After = func(c *cli.Context) error { + for _, f := range []cli.AfterFunc{f, tt.after} { + if f != nil { + if err := f(c); err != nil { + return err + } + } + } + return nil + } } - err := app.RunContext(context.TODO(), tt.args) + err := app.RunContext(context.Background(), tt.args) switch tt.err == "" { case true: a.NoError(err) @@ -142,14 +182,4 @@ func harnessFunc(t *testing.T, tt harness, mux *http.ServeMux, cmd func() *cli.C a.Error(err) a.Contains(err.Error(), tt.err) } - - for key, value := range tt.counters { - counter, err := findCounter(app, key) - a.NoError(err) - a.Equalf(value, counter.Count, key) - } - - if tt.after != nil { - tt.after(app) - } } diff --git a/metadata.go b/metadata.go index 7561c25..84a4fc1 100644 --- a/metadata.go +++ b/metadata.go @@ -1,6 +1,9 @@ package ma import ( + "errors" + "unicode" + "github.com/armon/go-metrics" "github.com/bzimmer/smugmug" "github.com/rs/zerolog/log" @@ -67,7 +70,7 @@ func albumOrNode(c *cli.Context) error { return nil } -// Stats logs and encodes (if requested) the stats +// Stats logs and encodes (if enabled) the stats func Stats(c *cli.Context) error { data := sink(c).Data() for i := range data { @@ -91,3 +94,16 @@ func Stats(c *cli.Context) error { } return encoder(c).Encode(data) } + +var ErrInvalidURLName = errors.New("node url name must start with a number or capital letter") + +func validateURLName(urlName string) error { + v := rune(urlName[0]) + if unicode.IsNumber(v) { + return nil + } + if !unicode.IsUpper(rune(urlName[0])) { + return ErrInvalidURLName + } + return nil +} diff --git a/new.go b/new.go index b3bb96d..2c64575 100644 --- a/new.go +++ b/new.go @@ -3,7 +3,6 @@ package ma import ( "fmt" "strings" - "unicode" "github.com/bzimmer/smugmug" "github.com/rs/zerolog/log" @@ -18,8 +17,8 @@ func knew(c *cli.Context) error { url = smugmug.URLName(name) case 2: url = c.Args().Get(1) - if !unicode.IsUpper(rune(url[0])) { - return fmt.Errorf("node url name must start with a capital letter") + if err := validateURLName(url); err != nil { + return err } } diff --git a/new_test.go b/new_test.go index 08ebf25..a01f972 100644 --- a/new_test.go +++ b/new_test.go @@ -76,7 +76,7 @@ func TestNew(t *testing.T) { { name: "new with invalid url name", args: []string{"ma", "new", "--parent", "QWERTY0", "album", "0YTREWQ", "lower-case"}, - err: "node url name must start with a capital letter", + err: ma.ErrInvalidURLName.Error(), }, { name: "new album", @@ -85,7 +85,7 @@ func TestNew(t *testing.T) { } { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandNew) + run(t, tt, mux, ma.CommandNew) }) } } diff --git a/patch.go b/patch.go index 1800449..8bf2f49 100644 --- a/patch.go +++ b/patch.go @@ -1,132 +1,216 @@ package ma import ( + "errors" "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" ) -const keywordArray = "KeywordArray" +type patchFunc func(c *cli.Context) (bool, string, interface{}, error) -type patchFunc func(c *cli.Context) (bool, string, interface{}) +type patcherFunc func(*cli.Context, string, map[string]interface{}) error func patchFuncs() []patchFunc { return []patchFunc{ keywords, - str("caption"), + str("name"), str("title"), + str("caption"), float("altitude"), float("latitude"), float("longitude"), + urlname("urlname"), } } -func keywords(c *cli.Context) (bool, string, interface{}) { +func keywords(c *cli.Context) (bool, string, interface{}, error) { if !c.IsSet("keyword") { - return false, "keywords", nil + return false, "keywords", nil, nil } var kws []string + keyword := "KeywordArray" for _, kw := range c.StringSlice("keyword") { switch kw { case "": - return true, keywordArray, []string{} + return true, keyword, []string{}, nil default: kws = append(kws, kw) } } - return true, keywordArray, kws + return true, keyword, kws, nil } func str(key string) patchFunc { title := strings.Title(key) - return func(c *cli.Context) (bool, string, interface{}) { + return func(c *cli.Context) (bool, string, interface{}, error) { + if !c.IsSet(key) { + return false, key, nil, nil + } + return true, title, c.String(key), nil + } +} + +func urlname(key string) patchFunc { + title := "UrlName" + return func(c *cli.Context) (bool, string, interface{}, error) { if !c.IsSet(key) { - return false, key, nil + return false, key, nil, nil + } + url := c.String(key) + if err := validateURLName(url); err != nil { + log.Error().Err(err).Str("urlname", url).Msg("invalid") + return false, key, nil, err } - return true, title, c.String(key) + return true, title, url, nil } } func float(key string) patchFunc { title := strings.Title(key) - return func(c *cli.Context) (bool, string, interface{}) { + return func(c *cli.Context) (bool, string, interface{}, error) { if !c.IsSet(key) { - return false, key, nil + return false, key, nil, nil } - return true, title, c.Float64(key) + return true, title, c.Float64(key), nil } } -func patch(c *cli.Context) error { - patches := make(map[string]interface{}) - for _, f := range patchFuncs() { - ok, key, value := f(c) - if ok { - patches[key] = value - } +func imagePatcher(c *cli.Context, imageKey string, patches map[string]interface{}) error { + img, err := client(c).Image.Patch(c.Context, imageKey, patches) + if err != nil { + return err + } + f := imageIterFunc(c, nil, "patch") + if _, err := f(img); err != nil { + return err + } + return nil +} + +func albumPatcher(c *cli.Context, albumKey string, patches map[string]interface{}) error { + album, err := client(c).Album.Patch(c.Context, albumKey, patches) + if err != nil { + return err + } + f := albumIterFunc(c, "patch") + if _, err := f(album); err != nil { + return err } + return nil +} - for _, imageKey := range c.Args().Slice() { - switch { - case len(patches) == 0: - log.Warn().Str("imageKey", imageKey).Msg("no patches to apply") - case !c.Bool("force"): - metric(c).IncrCounter([]string{"patch", "patched", "dryrun"}, 1) - log.Info().Str("imageKey", imageKey).Interface("patches", patches).Msg("dryrun") - default: - metric(c).IncrCounter([]string{"patch", "patched"}, 1) - log.Info().Str("imageKey", imageKey).Interface("patches", patches).Msg("applying") - img, err := client(c).Image.Patch(c.Context, imageKey, patches) +func patch(keyName string, patcher patcherFunc) cli.ActionFunc { + return func(c *cli.Context) error { + patches := make(map[string]interface{}) + for _, f := range patchFuncs() { + ok, key, value, err := f(c) if err != nil { return err } - f := imageIterFunc(c, nil, "patch") - if _, err := f(img); err != nil { - return err + if ok { + patches[key] = value + } + } + for _, x := range c.Args().Slice() { + switch { + case len(patches) == 0: + log.Warn().Str(keyName, x).Msg("no patches to apply") + case !c.Bool("force"): + metric(c).IncrCounter([]string{"patch", c.Command.Name, "dryrun"}, 1) + log.Info().Str(keyName, x).Interface("patches", patches).Msg("dryrun") + default: + log.Info().Str(keyName, x).Interface("patches", patches).Msg("applying") + if err := patcher(c, x, patches); err != nil { + return err + } } } + return nil } - return nil } -func CommandPatch() *cli.Command { +func forceFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "force must be specified to apply the patch", + Value: false, + } +} + +func albumPatch() *cli.Command { return &cli.Command{ - Name: "patch", - HelpName: "patch", + Name: "album", + HelpName: "album", + Usage: "patch an album (or albums)", + ArgsUsage: " [, ...]", + Flags: []cli.Flag{ + forceFlag(), + &cli.StringSliceFlag{ + Name: "keyword", + }, + &cli.StringFlag{ + Name: "name", + }, + &cli.StringFlag{ + Name: "urlname", + }, + }, + Before: func(c *cli.Context) error { + switch c.NArg() { + case 0: + return errors.New("expected one albumKey argument") + case 1: + return nil + default: + return errors.New("expected only one albumKey argument") + } + }, + Action: patch("albumKey", albumPatcher), + } +} + +func imagePatch() *cli.Command { + return &cli.Command{ + Name: "image", + HelpName: "image", Usage: "patch an image (or images)", ArgsUsage: " [, ...]", Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Usage: "force must be specified to apply the patch", - Value: false, - }, + forceFlag(), &cli.StringSliceFlag{ - Name: "keyword", - Required: false, + Name: "keyword", }, &cli.StringFlag{ - Name: "caption", - Required: false, + Name: "caption", }, &cli.StringFlag{ - Name: "title", - Required: false, + Name: "title", }, &cli.Float64Flag{ - Name: "latitude", - Required: false, + Name: "latitude", }, &cli.Float64Flag{ - Name: "longitude", - Required: false, + Name: "longitude", }, &cli.Float64Flag{ - Name: "altitude", - Required: false, + Name: "altitude", }, }, - Action: patch, + Action: patch("imageKey", imagePatcher), + } +} + +func CommandPatch() *cli.Command { + return &cli.Command{ + Name: "patch", + HelpName: "patch", + Usage: "patch the metadata for albums and images", + Subcommands: []*cli.Command{ + albumPatch(), + imagePatch(), + }, } } diff --git a/patch_test.go b/patch_test.go index 6d62e42..94b62c3 100644 --- a/patch_test.go +++ b/patch_test.go @@ -1,6 +1,7 @@ package ma_test import ( + "encoding/json" "net/http" "testing" @@ -13,25 +14,84 @@ func TestPatch(t *testing.T) { a := assert.New(t) mux := http.NewServeMux() + mux.HandleFunc("/image/GH8UQ9-0", func(w http.ResponseWriter, r *http.Request) { + a.Fail("should not be called") + }) mux.HandleFunc("/image/B2fHSt7-0", func(w http.ResponseWriter, r *http.Request) { a.NoError(copyFile(w, "testdata/image_B2fHSt7-0.json")) }) + mux.HandleFunc("/image/B2fHSt7-1", func(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + dec := json.NewDecoder(r.Body) + a.NoError(dec.Decode(&data)) + a.Contains(data, "Latitude") + a.NoError(copyFile(w, "testdata/image_B2fHSt7-0.json")) + }) + mux.HandleFunc("/image/B2fHSt7-2", func(w http.ResponseWriter, r *http.Request) { + a.Fail("should not be called") + }) + mux.HandleFunc("/image/B2fHSt7-3", func(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + dec := json.NewDecoder(r.Body) + a.NoError(dec.Decode(&data)) + a.Contains(data, "KeywordArray") + a.Empty(data["KeywordArray"]) + a.NoError(copyFile(w, "testdata/image_B2fHSt7-0.json")) + }) + mux.HandleFunc("/album/RM4BL2", func(w http.ResponseWriter, r *http.Request) { + data := make(map[string]interface{}) + dec := json.NewDecoder(r.Body) + a.NoError(dec.Decode(&data)) + a.Contains(data, "Name") + a.Contains(data, "UrlName") + a.NoError(copyFile(w, "testdata/album_RM4BL2.json")) + }) for _, tt := range []harness{ { name: "no force", - args: []string{"ma", "patch", "--keyword", "foo", "GH8UQ9-0"}, - counters: map[string]int{"ma.patch.patched.dryrun": 1}, + args: []string{"ma", "patch", "image", "--keyword", "foo", "GH8UQ9-0"}, + counters: map[string]int{"ma.patch.image.dryrun": 1}, + }, + { + name: "force", + args: []string{"ma", "patch", "image", "--force", "--keyword", "foo", "B2fHSt7-0"}, + counters: map[string]int{"ma.patch.image": 1}, }, { name: "force", - args: []string{"ma", "patch", "--force", "--keyword", "foo", "B2fHSt7-0"}, - counters: map[string]int{"ma.patch.patched": 1}, + args: []string{"ma", "patch", "image", "--force", "--latitude", "48.4321", "B2fHSt7-1"}, + counters: map[string]int{"ma.patch.image": 1}, + }, + { + name: "no patches", + args: []string{"ma", "patch", "image", "B2fHSt7-2"}, + }, + { + name: "empty keywords", + args: []string{"ma", "patch", "image", "--keyword", "", "B2fHSt7-3"}, + counters: map[string]int{"ma.patch.image.dryrun": 1}, + }, + { + name: "album", + args: []string{"ma", "patch", "album", "--force", + "--name", "2021-07-04 Fourth of July", "--urlname", "2021-07-04-Fourth-of-July", "RM4BL2"}, + counters: map[string]int{"ma.patch.album": 1}, + }, + { + name: "invalid url name", + args: []string{"ma", "patch", "album", "--force", "--urlname", "this-is-invalid", "RM4BL2"}, + err: ma.ErrInvalidURLName.Error(), + }, + { + name: "more than one album key", + args: []string{"ma", "patch", "album", "RM4BL2", "XM4BL2"}, + err: "expected only one albumKey argument", }, } { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandPatch) + run(t, tt, mux, ma.CommandPatch) }) } } diff --git a/up_test.go b/up_test.go index 96fedf0..5a48e16 100644 --- a/up_test.go +++ b/up_test.go @@ -51,8 +51,9 @@ func TestUpload(t *testing.T) { //nolint { name: "upload no valid files", args: []string{"ma", "upload", "--album", "TDZWbg", "/foo/bar"}, - before: func(app *cli.App) { - a.NoError(runtime(app).Fs.MkdirAll("/foo/bar", 0755)) + before: func(c *cli.Context) error { + a.NoError(runtime(c).Fs.MkdirAll("/foo/bar", 0755)) + return nil }, }, { @@ -63,12 +64,13 @@ func TestUpload(t *testing.T) { //nolint "ma.fsUploadable.open": 1, "ma.fsUploadable.skip.md5": 1, }, - before: func(app *cli.App) { - fp, err := runtime(app).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + before: func(c *cli.Context) error { + fp, err := runtime(c).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.NotNil(fp) a.NoError(err) a.NoError(copyFile(fp, "testdata/Nikon_D70.jpg")) a.NoError(fp.Close()) + return nil }, }, { @@ -78,11 +80,12 @@ func TestUpload(t *testing.T) { //nolint "ma.fsUploadable.visit": 1, "ma.fsUploadable.skip.unsupported": 1, }, - before: func(app *cli.App) { - fp, err := runtime(app).Fs.Create("/foo/bar/Nikon_D70.xmp") + before: func(c *cli.Context) error { + fp, err := runtime(c).Fs.Create("/foo/bar/Nikon_D70.xmp") a.NotNil(fp) a.NoError(err) a.NoError(fp.Close()) + return nil }, }, { @@ -94,14 +97,15 @@ func TestUpload(t *testing.T) { //nolint "ma.fsUploadable.replace": 1, "ma.upload.dryrun": 1, }, - before: func(app *cli.App) { + before: func(c *cli.Context) error { // create a file of the same name as a previously uploaded file but copy the // contents of a different file to force the md5s to be different - fp, err := runtime(app).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") + fp, err := runtime(c).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Nikon_D70.jpg") a.NotNil(fp) a.NoError(err) a.NoError(copyFile(fp, "testdata/Fujifilm_FinePix6900ZOOM.jpg")) a.NoError(fp.Close()) + return nil }, }, { @@ -113,12 +117,13 @@ func TestUpload(t *testing.T) { //nolint "ma.fsUploadable.open": 1, "ma.upload.success": 1, }, - before: func(app *cli.App) { - fp, err := runtime(app).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Fujifilm_FinePix6900ZOOM.jpg") + before: func(c *cli.Context) error { + fp, err := runtime(c).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Fujifilm_FinePix6900ZOOM.jpg") a.NotNil(fp) a.NoError(err) a.NoError(copyFile(fp, "testdata/Fujifilm_FinePix6900ZOOM.jpg")) a.NoError(fp.Close()) + return nil }, }, { @@ -129,18 +134,19 @@ func TestUpload(t *testing.T) { //nolint "ma.upload.dryrun": 1, "ma.fsUploadable.open": 1, }, - before: func(app *cli.App) { - fp, err := runtime(app).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Fujifilm_FinePix6900ZOOM.jpg") + before: func(c *cli.Context) error { + fp, err := runtime(c).Fs.Create("/foo/bar/hdxDH/VsQ7zr/Fujifilm_FinePix6900ZOOM.jpg") a.NotNil(fp) a.NoError(err) a.NoError(copyFile(fp, "testdata/Fujifilm_FinePix6900ZOOM.jpg")) a.NoError(fp.Close()) + return nil }, }, } { tt := tt t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandUpload) + run(t, tt, mux, ma.CommandUpload) }) } } diff --git a/user_test.go b/user_test.go index 2a8b0b8..aeede07 100644 --- a/user_test.go +++ b/user_test.go @@ -24,7 +24,7 @@ func TestUser(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - harnessFunc(t, tt, mux, ma.CommandUser) + run(t, tt, mux, ma.CommandUser) }) } } diff --git a/version_test.go b/version_test.go index 5d3faa8..647d6f0 100644 --- a/version_test.go +++ b/version_test.go @@ -12,6 +12,6 @@ import ( func TestVersion(t *testing.T) { a := assert.New(t) - app := NewTestApp(t, "version", ma.CommandVersion(), "") + app := NewTestApp(t, harness{name: "version"}, ma.CommandVersion(), "") a.NoError(app.RunContext(context.TODO(), []string{"ma", "version"})) }