diff --git a/internal/api/cameras.go b/internal/api/cameras.go new file mode 100644 index 000000000..1c44f1523 --- /dev/null +++ b/internal/api/cameras.go @@ -0,0 +1,81 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/auth/acl" + "github.com/photoprism/photoprism/internal/entity/query" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/i18n" +) + +// UpdateCamera updates camera make and model properties. +// +// PUT /api/v1/cameras/:id +// +// @Summary updates camera name +// @Id UpdateCamera +// @Tags Cameras +// @Accept json +// @Produce json +// @Success 200 {object} entity.Camera +// @Failure 401,403,404,429 {object} i18n.Response +// @Param id path string true "Camera ID" +// @Param camera body form.Camera true "Properties to be updated, only Make and Model supported" +// @Router /api/v1/cameras/{id} [put] +func UpdateCamera(router *gin.RouterGroup) { + router.PUT("/cameras/:id", func(c *gin.Context) { + s := Auth(c, acl.ResourceCameras, acl.ActionUpdate) + + if s.Abort(c) { + return + } + + // Find camera by ID. A non-numeric id parses to 0 and is rejected as not found below, + // so the parse error is intentionally ignored. + cameraId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32) + m := query.FindCameraByID(uint(cameraId)) + + if m == nil { + Abort(c, http.StatusNotFound, i18n.ErrCameraNotFound) + return + } + + // Create new camera form. + frm, frmErr := form.NewCamera(m) + + if frmErr != nil { + Abort(c, http.StatusBadRequest, i18n.ErrBadRequest) + return + } + + // Set form values from request. + LimitRequestBodyBytes(c, MaxMutationRequestBytes) + + if frmErr = c.BindJSON(frm); frmErr != nil { + if IsRequestBodyTooLarge(frmErr) { + AbortRequestTooLarge(c, i18n.ErrBadRequest) + return + } + + AbortBadRequest(c, frmErr) + return + } else if frmErr = frm.Validate(); frmErr != nil { + AbortInvalidName(c) + return + } + + // Save camera and return new model values if successful. + if err := m.SaveForm(frm); err != nil { + log.Errorf("camera: %s", clean.Error(err)) + AbortSaveFailed(c) + return + } + + c.JSON(http.StatusOK, m) + }) +} diff --git a/internal/api/cameras_search.go b/internal/api/cameras_search.go new file mode 100644 index 000000000..01a0b054b --- /dev/null +++ b/internal/api/cameras_search.go @@ -0,0 +1,63 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/photoprism/photoprism/internal/auth/acl" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/txt" +) + +// SearchCameras finds and returns cameras as JSON. +// +// @Summary finds and returns cameras as JSON +// @Id SearchCameras +// @Tags Cameras +// @Produce json +// @Success 200 {array} search.Camera +// @Header 200 {number} X-Count "The actual number of cameras returned" +// @Header 200 {number} X-Limit "The limit of the number of cameras to be returned" +// @Header 200 {number} X-Offset "The offset that was used" +// @Failure 401,429,403,400 {object} i18n.Response +// @Param count query int true "maximum number of results" minimum(1) maximum(100000) +// @Param offset query int false "search result offset" minimum(0) maximum(100000) +// @Param nomake query bool false "show where make is blank" +// @Param q query string false "search query" +// @Router /api/v1/cameras [get] +func SearchCameras(router *gin.RouterGroup) { + router.GET("/cameras", func(c *gin.Context) { + s := Auth(c, acl.ResourceCameras, acl.ActionSearch) + + if s.Abort(c) { + return + } + + var frm form.SearchCameras + + err := c.MustBindWith(&frm, binding.Form) + + if err != nil { + AbortBadRequest(c, err) + return + } + + // Search matching cameras. + result, err := search.Cameras(frm) + + if err != nil { + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) + return + } + + AddCountHeader(c, len(result)) + AddLimitHeader(c, frm.Count) + AddOffsetHeader(c, frm.Offset) + AddTokenHeaders(c, s) + + c.JSON(http.StatusOK, result) + }) +} diff --git a/internal/api/cameras_search_test.go b/internal/api/cameras_search_test.go new file mode 100644 index 000000000..aa099a067 --- /dev/null +++ b/internal/api/cameras_search_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "strconv" + "testing" + + "github.com/tidwall/gjson" + + "github.com/stretchr/testify/assert" +) + +func TestSearchCameras(t *testing.T) { + t.Run("Success", func(t *testing.T) { + app, router, _ := NewApiTest() + SearchCameras(router) + r := PerformRequest(app, "GET", "/api/v1/cameras?count=15") + count := gjson.Get(r.Body.String(), "#") + assert.Greater(t, count.Int(), int64(0)) + assert.Equal(t, http.StatusOK, r.Code) + result := r.Result() + xCount, err := strconv.Atoi(result.Header.Get("X-Count")) + assert.NoError(t, err, "strconv for X-Count failed") + // X-Count must equal the number of records in the body. + assert.Equal(t, int(count.Int()), xCount) + }) + t.Run("InvalidRequest", func(t *testing.T) { + app, router, _ := NewApiTest() + SearchCameras(router) + r := PerformRequest(app, "GET", "/api/v1/cameras?xxx=15") + assert.Equal(t, http.StatusBadRequest, r.Code) + }) +} diff --git a/internal/api/cameras_test.go b/internal/api/cameras_test.go new file mode 100644 index 000000000..11f361255 --- /dev/null +++ b/internal/api/cameras_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/tidwall/gjson" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity" +) + +func TestUpdateCamera(t *testing.T) { + t.Run("Success", func(t *testing.T) { + defer func() { + entity.FlushCameraCache() + assert.NoError(t, entity.UnscopedDb().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error) + }() + app, router, _ := NewApiTest() + UpdateCamera(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "Pentax", "Model": "K-1"}`) + val := gjson.Get(r.Body.String(), "Name") + assert.Equal(t, "PENTAX K-1", val.String()) + val2 := gjson.Get(r.Body.String(), "Model") + assert.Equal(t, "K-1", val2.String()) + val3 := gjson.Get(r.Body.String(), "Make") + assert.Equal(t, "PENTAX", val3.String()) + assert.Equal(t, http.StatusOK, r.Code) + }) + t.Run("InvalidRequest", func(t *testing.T) { + app, router, _ := NewApiTest() + UpdateCamera(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": 123, "Model": ""}`) + val := gjson.Get(r.Body.String(), "error") + assert.Equal(t, "Unable to do that", val.String()) + assert.Equal(t, http.StatusBadRequest, r.Code) + }) + t.Run("BadModel", func(t *testing.T) { + app, router, _ := NewApiTest() + UpdateCamera(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/1000002", `{"Make": "123", "Model": ""}`) + val := gjson.Get(r.Body.String(), "error") + assert.Equal(t, "Invalid name", val.String()) + assert.Equal(t, http.StatusBadRequest, r.Code) + }) + t.Run("NotFound", func(t *testing.T) { + app, router, _ := NewApiTest() + UpdateCamera(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/cameras/199000002", `{"Make": "Pentax", "Model": "K-1"}`) + val := gjson.Get(r.Body.String(), "error") + assert.Equal(t, "Camera not found", val.String()) + assert.Equal(t, http.StatusNotFound, r.Code) + }) +} diff --git a/internal/api/lenses.go b/internal/api/lenses.go index da1fa18ce..51f8a310e 100644 --- a/internal/api/lenses.go +++ b/internal/api/lenses.go @@ -35,8 +35,9 @@ func UpdateLens(router *gin.RouterGroup) { return } - // Find lens by ID. - lensId, err := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32) + // Find lens by ID. A non-numeric id parses to 0 and is rejected as not found below, + // so the parse error is intentionally ignored. + lensId, _ := strconv.ParseUint(clean.Token(c.Param("id")), 10, 32) m := query.FindLensByID(uint(lensId)) if m == nil { @@ -69,7 +70,7 @@ func UpdateLens(router *gin.RouterGroup) { } // Save lens and return new model values if successful. - if err = m.SaveForm(frm); err != nil { + if err := m.SaveForm(frm); err != nil { log.Errorf("lens: %s", clean.Error(err)) AbortSaveFailed(c) return diff --git a/internal/api/lenses_search.go b/internal/api/lenses_search.go index 133d3ff59..5f36e9333 100644 --- a/internal/api/lenses_search.go +++ b/internal/api/lenses_search.go @@ -19,6 +19,9 @@ import ( // @Tags Lenses // @Produce json // @Success 200 {array} search.Lens +// @Header 200 {number} X-Count "The actual number of lenses returned" +// @Header 200 {number} X-Limit "The limit of the number of lenses to be returned" +// @Header 200 {number} X-Offset "The offset that was used" // @Failure 401,429,403,400 {object} i18n.Response // @Param count query int true "maximum number of results" minimum(1) maximum(100000) // @Param offset query int false "search result offset" minimum(0) maximum(100000) @@ -50,7 +53,7 @@ func SearchLenses(router *gin.RouterGroup) { return } - // TODO c.Header("X-Count", strconv.Itoa(count)) + AddCountHeader(c, len(result)) AddLimitHeader(c, frm.Count) AddOffsetHeader(c, frm.Offset) AddTokenHeaders(c, s) diff --git a/internal/api/lenses_search_test.go b/internal/api/lenses_search_test.go index 057a11628..afda98349 100644 --- a/internal/api/lenses_search_test.go +++ b/internal/api/lenses_search_test.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strconv" "testing" "github.com/tidwall/gjson" @@ -15,8 +16,13 @@ func TestSearchLenses(t *testing.T) { SearchLenses(router) r := PerformRequest(app, "GET", "/api/v1/lenses?count=15") count := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(3), count.Int()) + assert.Greater(t, count.Int(), int64(0)) assert.Equal(t, http.StatusOK, r.Code) + result := r.Result() + xCount, err := strconv.Atoi(result.Header.Get("X-Count")) + assert.NoError(t, err, "strconv for X-Count failed") + // X-Count must equal the number of records in the body. + assert.Equal(t, int(count.Int()), xCount) }) t.Run("InvalidRequest", func(t *testing.T) { app, router, _ := NewApiTest() diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 17a2f66ee..171cf4aa4 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -920,6 +920,9 @@ "calendar": { "type": "boolean" }, + "cameras": { + "type": "boolean" + }, "delete": { "type": "boolean" }, @@ -2675,6 +2678,17 @@ }, "type": "object" }, + "form.Camera": { + "properties": { + "Make": { + "type": "string" + }, + "Model": { + "type": "string" + } + }, + "type": "object" + }, "form.ChangePassword": { "properties": { "new": { @@ -3955,6 +3969,44 @@ }, "type": "object" }, + "search.Camera": { + "properties": { + "CreatedAt": { + "type": "string" + }, + "DeletedAt": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "ID": { + "type": "integer" + }, + "Make": { + "type": "string" + }, + "Model": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Notes": { + "type": "string" + }, + "Slug": { + "type": "string" + }, + "Type": { + "type": "string" + }, + "UpdatedAt": { + "type": "string" + } + }, + "type": "object" + }, "search.Face": { "properties": { "CollisionRadius": { @@ -6766,6 +6818,163 @@ ] } }, + "/api/v1/cameras": { + "get": { + "operationId": "SearchCameras", + "parameters": [ + { + "description": "maximum number of results", + "in": "query", + "maximum": 100000, + "minimum": 1, + "name": "count", + "required": true, + "type": "integer" + }, + { + "description": "search result offset", + "in": "query", + "maximum": 100000, + "minimum": 0, + "name": "offset", + "type": "integer" + }, + { + "description": "show where make is blank", + "in": "query", + "name": "nomake", + "type": "boolean" + }, + { + "description": "search query", + "in": "query", + "name": "q", + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Count": { + "description": "The actual number of cameras returned", + "type": "number" + }, + "X-Limit": { + "description": "The limit of the number of cameras to be returned", + "type": "number" + }, + "X-Offset": { + "description": "The offset that was used", + "type": "number" + } + }, + "schema": { + "items": { + "$ref": "#/definitions/search.Camera" + }, + "type": "array" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "finds and returns cameras as JSON", + "tags": [ + "Cameras" + ] + } + }, + "/api/v1/cameras/{id}": { + "put": { + "consumes": [ + "application/json" + ], + "operationId": "UpdateCamera", + "parameters": [ + { + "description": "Camera ID", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "Properties to be updated, only Make and Model supported", + "in": "body", + "name": "camera", + "required": true, + "schema": { + "$ref": "#/definitions/form.Camera" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/entity.Camera" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "updates camera name", + "tags": [ + "Cameras" + ] + } + }, "/api/v1/cluster": { "get": { "operationId": "ClusterSummary", @@ -8736,6 +8945,20 @@ "responses": { "200": { "description": "OK", + "headers": { + "X-Count": { + "description": "The actual number of lenses returned", + "type": "number" + }, + "X-Limit": { + "description": "The limit of the number of lenses to be returned", + "type": "number" + }, + "X-Offset": { + "description": "The offset that was used", + "type": "number" + } + }, "schema": { "items": { "$ref": "#/definitions/search.Lens" diff --git a/internal/auth/acl/const.go b/internal/auth/acl/const.go index 5230ae13a..c60662344 100644 --- a/internal/auth/acl/const.go +++ b/internal/auth/acl/const.go @@ -65,6 +65,7 @@ const ( ResourcePeople Resource = "people" ResourcePlaces Resource = "places" ResourceLabels Resource = "labels" + ResourceCameras Resource = "cameras" ResourceLenses Resource = "lenses" ResourceConfig Resource = "config" ResourceSettings Resource = "settings" diff --git a/internal/auth/acl/resource_names.go b/internal/auth/acl/resource_names.go index b8801671d..cf0e0d17d 100644 --- a/internal/auth/acl/resource_names.go +++ b/internal/auth/acl/resource_names.go @@ -14,6 +14,7 @@ var ResourceNames = []Resource{ ResourcePeople, ResourcePlaces, ResourceLabels, + ResourceCameras, ResourceLenses, ResourceConfig, ResourceSettings, diff --git a/internal/auth/acl/rules.go b/internal/auth/acl/rules.go index 8597f4c06..21257316a 100644 --- a/internal/auth/acl/rules.go +++ b/internal/auth/acl/rules.go @@ -57,9 +57,15 @@ var Rules = ACL{ RoleAdmin: GrantFullAccess, RoleClient: GrantFullAccess, }, + ResourceCameras: Roles{ + RoleAdmin: GrantFullAccess, + RoleService: GrantFullAccess, + RoleClient: GrantFullAccess, + }, ResourceLenses: Roles{ - RoleAdmin: GrantFullAccess, - RoleClient: GrantFullAccess, + RoleAdmin: GrantFullAccess, + RoleService: GrantFullAccess, + RoleClient: GrantFullAccess, }, ResourceConfig: Roles{ RoleAdmin: GrantFullAccess, diff --git a/internal/auth/acl/scope_descriptions.go b/internal/auth/acl/scope_descriptions.go index d6a0a92ed..5a0688a5d 100644 --- a/internal/auth/acl/scope_descriptions.go +++ b/internal/auth/acl/scope_descriptions.go @@ -17,7 +17,8 @@ var ScopeDescriptions = map[string]string{ ResourcePeople.String(): "Manage people records and face assignments.", ResourcePlaces.String(): "Access maps, locations, and place clusters.", ResourceLabels.String(): "Manage subject labels and keywords.", - ResourceLenses.String(): "Manage camera lense make and model values.", + ResourceCameras.String(): "Manage camera make and model values.", + ResourceLenses.String(): "Manage lens make and model values.", ResourceConfig.String(): "Read configuration reports and summaries.", ResourceSettings.String(): "Read and update application settings.", ResourcePasscode.String(): "Manage app passcodes and guest access codes.", diff --git a/internal/commands/cameras.go b/internal/commands/cameras.go new file mode 100644 index 000000000..f22ac075a --- /dev/null +++ b/internal/commands/cameras.go @@ -0,0 +1,143 @@ +package commands + +import ( + "fmt" + "strconv" + "strings" + + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity/query" + "github.com/photoprism/photoprism/internal/entity/search" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/txt/report" +) + +// CamerasCommand registers the "cameras" CLI command. +var CamerasCommand = &cli.Command{ + Name: "cameras", + Usage: "Camera management subcommands", + Subcommands: []*cli.Command{ + CamerasListCommand, + CamerasUpdateCommand, + }, +} + +// CamerasListCommand registers the list sub command. +var CamerasListCommand = &cli.Command{ + Name: "ls", + Usage: "Lists discovered cameras", + ArgsUsage: "[query]", + Flags: append(report.CliFlags, CountFlag, OffsetFlag, NoMakeFlag), + Action: camerasListAction, +} + +// CamerasUpdateCommand registers the update sub command. +var CamerasUpdateCommand = &cli.Command{ + Name: "update", + Usage: "Updates a specific camera Make and Model", + Flags: []cli.Flag{ + &cli.UintFlag{Name: "id", Usage: "camera id", Required: true}, + &cli.StringFlag{Name: "make", Usage: "the make of the camera", Required: true}, + &cli.StringFlag{Name: "model", Usage: "the model of the camera", Required: true}, + }, + Action: camerasUpdateAction, +} + +// camerasListAction searches the database for cameras. +func camerasListAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + + filter := strings.TrimSpace(strings.Join(ctx.Args().Slice(), " ")) + // Pagination identical to API defaults. + count := int(ctx.Uint("count")) //nolint:gosec // CLI flag bounded by validation + if count <= 0 || count > 1000 { + count = 100 + } + offset := max(ctx.Int("offset"), 0) + + frm := form.SearchCameras{ + Query: filter, + NoMake: ctx.Bool("nomake"), + Count: count, + Offset: offset, + } + + results, err := search.Cameras(frm) + + if err != nil { + return err + } + + format := report.CliFormat(ctx) + + cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"} + rows := make([][]string, 0, len(results)) + + for _, found := range results { + v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")} + rows = append(rows, v) + } + + result, err := report.RenderFormat(rows, cols, format) + + if err != nil { + return err + } + + fmt.Println(result) + + return nil + }) +} + +// camerasUpdateAction updates the make and model of a specific camera. +func camerasUpdateAction(ctx *cli.Context) error { + return CallWithDependencies(ctx, func(conf *config.Config) error { + + cameraId := ctx.Uint("id") + cameraMake := ctx.String("make") + cameraModel := ctx.String("model") + + camera := query.FindCameraByID(cameraId) + if camera == nil { + return cli.Exit("camera not found", 1) + } + if err := camera.UpdateMakeModel(cameraMake, cameraModel); err != nil { + return cli.Exit(err, 1) + } + + frm := form.SearchCameras{ + ID: strconv.FormatUint(uint64(camera.ID), 10), + Count: 10, + Offset: 0, + } + + results, err := search.Cameras(frm) + + if err != nil { + return err + } + + format := report.CliFormat(ctx) + + cols := []string{"ID", "Camera Slug", "Camera Name", "Camera Make", "Camera Model", "Updated At"} + rows := make([][]string, 0, len(results)) + + for _, found := range results { + v := []string{strconv.FormatUint(uint64(found.ID), 10), found.CameraSlug, found.CameraName, found.CameraMake, found.CameraModel, found.UpdatedAt.Format("2006-01-02 15:04:05")} + rows = append(rows, v) + } + + result, err := report.RenderFormat(rows, cols, format) + + if err != nil { + return err + } + + fmt.Println(result) + + return nil + }) +} diff --git a/internal/commands/cameras_test.go b/internal/commands/cameras_test.go new file mode 100644 index 000000000..f337e3eba --- /dev/null +++ b/internal/commands/cameras_test.go @@ -0,0 +1,86 @@ +package commands + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + + "github.com/photoprism/photoprism/internal/entity" +) + +func TestCamerasCommand(t *testing.T) { + t.Run("ListNoOptions", func(t *testing.T) { + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls"}) + assert.NoError(t, err) + + // Check command output for plausibility. + for _, expect := range entity.CameraFixtures { + assert.Contains(t, output, strconv.FormatUint(uint64(expect.ID), 10)) + assert.Contains(t, output, expect.CameraSlug) + assert.Contains(t, output, expect.CameraName) + } + }) + t.Run("ListWithCount", func(t *testing.T) { + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--count=1", "--offset=0"}) + assert.NoError(t, err) + + // Canon EOS 7D sorts last by make/model/slug, so it must not appear in the first row. + assert.NotEmpty(t, output) + assert.NotContains(t, output, "1000002") + }) + t.Run("ListWithNoMake", func(t *testing.T) { + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "ls", "--nomake"}) + assert.NoError(t, err) + + // Only the unknown camera has a blank make. + assert.Contains(t, output, "zz") + assert.Contains(t, output, "Unknown") + assert.NotContains(t, output, "1000002") + assert.NotContains(t, output, "Canon EOS 7D") + }) + t.Run("UpdateWithNoModel", func(t *testing.T) { + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Nikon"}) + assert.Error(t, err) + assert.Len(t, output, 0) + assert.Contains(t, err.Error(), `Required flag "model" not set`) + }) + t.Run("UpdateWithNoMake", func(t *testing.T) { + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", `--model=K-1`}) + assert.Error(t, err) + assert.Len(t, output, 0) + assert.Contains(t, err.Error(), `Required flag "make" not set`) + }) + t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) { + // Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel + // must reject them to prevent blanking a camera. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=", "--model="}) + assert.Error(t, err) + assert.Len(t, output, 0) + assert.Contains(t, err.Error(), "make and model must not be empty") + var exitErr cli.ExitCoder + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } + }) + t.Run("UpdateValid", func(t *testing.T) { + defer func() { + entity.FlushCameraCache() + assert.NoError(t, entity.Db().Save(entity.CameraFixtures.Pointer("canon-eos-7d")).Error) + }() + // Run command with test context. + output, err := RunWithTestContext(CamerasCommand, []string{"cameras", "update", "--id=1000002", "--make=Pentax", `--model=K-1`}) + assert.NoError(t, err) + + // Check command output for plausibility. + assert.Contains(t, output, "Updated At") + assert.Contains(t, output, "PENTAX K-1") + assert.Contains(t, output, "1000002") + }) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index a4fdce4bf..62f517d8c 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -61,6 +61,8 @@ var PhotoPrism = []*cli.Command{ DownloadCommand, VisionCommands, FacesCommands, + CamerasCommand, + LensesCommand, PlacesCommands, PurgeCommand, CleanUpCommand, @@ -85,7 +87,6 @@ var PhotoPrism = []*cli.Command{ EditionCommand, ShowConfigCommand, ConnectCommand, - LensesCommand, } // CountFlag represents a CLI flag to limit the number of report rows. diff --git a/internal/commands/lenses_test.go b/internal/commands/lenses_test.go index b8be579b3..cbe921576 100644 --- a/internal/commands/lenses_test.go +++ b/internal/commands/lenses_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" "github.com/photoprism/photoprism/internal/entity" ) @@ -83,6 +84,18 @@ func TestLensesCommand(t *testing.T) { assert.Len(t, output, 0) assert.Contains(t, err.Error(), `Required flag "make" not set`) }) + t.Run("UpdateWithEmptyMakeAndModel", func(t *testing.T) { + // Explicit empty strings satisfy the Required flag check, so the guard in UpdateMakeModel + // must reject them to prevent blanking a lens. + output, err := RunWithTestContext(LensesCommand, []string{"lenses", "update", "--id=1000002", "--make=", "--model="}) + assert.Error(t, err) + assert.Len(t, output, 0) + assert.Contains(t, err.Error(), "make and model must not be empty") + var exitErr cli.ExitCoder + if assert.ErrorAs(t, err, &exitErr) { + assert.Equal(t, 1, exitErr.ExitCode()) + } + }) t.Run("UpdateValid", func(t *testing.T) { defer assert.NoError(t, entity.Db().Save(entity.LensFixtures.Pointer("4-37")).Error) // Run command with test context. diff --git a/internal/config/client_config_test.go b/internal/config/client_config_test.go index 5e85fbeef..0fb4e29f8 100644 --- a/internal/config/client_config_test.go +++ b/internal/config/client_config_test.go @@ -171,6 +171,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) { Folders: true, Import: true, Labels: true, + Cameras: true, Lenses: true, Library: true, Logs: true, @@ -213,6 +214,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) { Folders: true, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, @@ -255,6 +257,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) { Folders: true, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, @@ -289,6 +292,7 @@ func TestConfig_ClientRoleConfig(t *testing.T) { assert.False(t, f.Calendar) assert.False(t, f.Moments) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -334,6 +338,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Calendar) assert.True(t, f.Moments) assert.True(t, f.Labels) + assert.True(t, f.Cameras) assert.True(t, f.Lenses) assert.True(t, f.People) assert.True(t, f.Settings) @@ -367,6 +372,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Calendar) assert.True(t, f.Moments) assert.True(t, f.Labels) + assert.True(t, f.Cameras) assert.True(t, f.Lenses) assert.True(t, f.People) assert.True(t, f.Settings) @@ -399,6 +405,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Albums) assert.False(t, f.Moments) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -433,6 +440,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Moments) assert.True(t, f.Folders) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -467,6 +475,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Moments) assert.True(t, f.Folders) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -500,6 +509,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.False(t, f.Albums) assert.False(t, f.Moments) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -531,6 +541,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.True(t, f.Calendar) assert.True(t, f.Moments) assert.True(t, f.Labels) + assert.True(t, f.Cameras) assert.True(t, f.Lenses) assert.True(t, f.People) assert.True(t, f.Settings) @@ -564,6 +575,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.False(t, f.Moments) assert.False(t, f.Folders) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.False(t, f.Settings) @@ -596,6 +608,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) { assert.False(t, f.Calendar) assert.False(t, f.Moments) assert.False(t, f.Labels) + assert.False(t, f.Cameras) assert.False(t, f.Lenses) assert.False(t, f.People) assert.True(t, f.Settings) diff --git a/internal/config/customize/acl.go b/internal/config/customize/acl.go index 5fd8d16d7..193883a45 100644 --- a/internal/config/customize/acl.go +++ b/internal/config/customize/acl.go @@ -13,6 +13,7 @@ func (s *Settings) ApplyACL(list acl.ACL, role acl.Role) *Settings { m.Features.Favorites = s.Features.Favorites && list.AllowAny(acl.ResourceFavorites, role, acl.Permissions{acl.ActionSearch}) m.Features.Folders = s.Features.Folders && list.AllowAny(acl.ResourceFolders, role, acl.Permissions{acl.ActionSearch}) m.Features.Labels = s.Features.Labels && list.AllowAny(acl.ResourceLabels, role, acl.Permissions{acl.ActionSearch}) + m.Features.Cameras = s.Features.Cameras && list.AllowAny(acl.ResourceCameras, role, acl.Permissions{acl.ActionSearch}) m.Features.Lenses = s.Features.Lenses && list.AllowAny(acl.ResourceLenses, role, acl.Permissions{acl.ActionSearch}) m.Features.Calendar = s.Features.Calendar && list.AllowAny(acl.ResourceCalendar, role, acl.Permissions{acl.ActionSearch}) m.Features.Moments = s.Features.Moments && list.AllowAny(acl.ResourceMoments, role, acl.Permissions{acl.ActionSearch}) diff --git a/internal/config/customize/acl_test.go b/internal/config/customize/acl_test.go index 9284da200..05214ab72 100644 --- a/internal/config/customize/acl_test.go +++ b/internal/config/customize/acl_test.go @@ -27,6 +27,7 @@ func TestSettings_ApplyACL(t *testing.T) { Folders: true, Import: true, Labels: true, + Cameras: true, Lenses: true, Library: true, Logs: true, @@ -70,6 +71,7 @@ func TestSettings_ApplyACL(t *testing.T) { Folders: true, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, diff --git a/internal/config/customize/feature.go b/internal/config/customize/feature.go index c665b3c04..e3b547e10 100644 --- a/internal/config/customize/feature.go +++ b/internal/config/customize/feature.go @@ -14,6 +14,7 @@ type FeatureSettings struct { Folders bool `json:"folders" yaml:"Folders"` Import bool `json:"import" yaml:"Import"` Labels bool `json:"labels" yaml:"Labels"` + Cameras bool `json:"cameras" yaml:"Cameras"` Lenses bool `json:"lenses" yaml:"Lenses"` Library bool `json:"library" yaml:"Library"` Logs bool `json:"logs" yaml:"Logs"` diff --git a/internal/config/customize/features_default_test.go b/internal/config/customize/features_default_test.go index bf91c752b..65f2d7636 100644 --- a/internal/config/customize/features_default_test.go +++ b/internal/config/customize/features_default_test.go @@ -21,7 +21,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) { DefaultFeatures = initDefaultFeatures() }) - _ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels lenses") + _ = os.Setenv("PHOTOPRISM_DISABLE_FEATURES", "Upload, videos share batch-edit labels cameras lenses") DefaultFeatures = initDefaultFeatures() assert.False(t, DefaultFeatures.Upload) @@ -29,6 +29,7 @@ func TestInitDefaultFeatures_DisableList(t *testing.T) { assert.False(t, DefaultFeatures.Share) assert.False(t, DefaultFeatures.BatchEdit) assert.False(t, DefaultFeatures.Labels) + assert.False(t, DefaultFeatures.Cameras) assert.False(t, DefaultFeatures.Lenses) // unaffected feature stays enabled diff --git a/internal/config/customize/scope.go b/internal/config/customize/scope.go index a9f44a30f..eefd826f7 100644 --- a/internal/config/customize/scope.go +++ b/internal/config/customize/scope.go @@ -20,6 +20,7 @@ func (s *Settings) ApplyScope(scope string) *Settings { m.Features.Favorites = s.Features.Favorites && scopes.Contains(acl.ResourceFavorites.String()) m.Features.Folders = s.Features.Folders && scopes.Contains(acl.ResourceFolders.String()) m.Features.Labels = s.Features.Labels && scopes.Contains(acl.ResourceLabels.String()) + m.Features.Cameras = s.Features.Cameras && scopes.Contains(acl.ResourceCameras.String()) m.Features.Lenses = s.Features.Lenses && scopes.Contains(acl.ResourceLenses.String()) m.Features.Calendar = s.Features.Calendar && scopes.Contains(acl.ResourceCalendar.String()) m.Features.Moments = s.Features.Moments && scopes.Contains(acl.ResourceMoments.String()) diff --git a/internal/config/customize/scope_test.go b/internal/config/customize/scope_test.go index d8e864dc8..e31fe9192 100644 --- a/internal/config/customize/scope_test.go +++ b/internal/config/customize/scope_test.go @@ -31,6 +31,7 @@ func TestSettings_ApplyScope(t *testing.T) { Folders: true, Import: true, Labels: true, + Cameras: true, Lenses: true, Library: true, Logs: true, @@ -74,6 +75,7 @@ func TestSettings_ApplyScope(t *testing.T) { Folders: false, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, @@ -117,6 +119,7 @@ func TestSettings_ApplyScope(t *testing.T) { Folders: true, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, @@ -159,6 +162,7 @@ func TestSettings_ApplyScope(t *testing.T) { Folders: true, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, @@ -201,6 +205,7 @@ func TestSettings_ApplyScope(t *testing.T) { Folders: false, Import: false, Labels: false, + Cameras: false, Lenses: false, Library: false, Logs: false, diff --git a/internal/config/customize/testdata/settings.yml b/internal/config/customize/testdata/settings.yml index e2c78dfe0..7539fe337 100755 --- a/internal/config/customize/testdata/settings.yml +++ b/internal/config/customize/testdata/settings.yml @@ -27,6 +27,7 @@ Features: Folders: true Import: true Labels: true + Cameras: true Lenses: true Library: true Logs: true diff --git a/internal/entity/camera.go b/internal/entity/camera.go index d4155fbf3..d2856d8d2 100644 --- a/internal/entity/camera.go +++ b/internal/entity/camera.go @@ -1,11 +1,15 @@ package entity import ( + "fmt" "strings" "sync" "time" + "github.com/ulule/deepcopier" + "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -35,6 +39,7 @@ func (Camera) TableName() string { return "cameras" } +// UnknownCamera is the placeholder used when no camera make or model is known. var UnknownCamera = Camera{ CameraSlug: UnknownID, CameraName: "Unknown", @@ -182,3 +187,58 @@ func (m *Camera) Mobile() bool { func (m *Camera) Unknown() bool { return m.CameraSlug == "" || m.CameraSlug == UnknownCamera.CameraSlug } + +// UpdateMakeModel updates the make and model of an existing camera, e.g. to fix entries that +// ExifTool decodes with a missing or garbled make. +// The camera slug is intentionally left unchanged so existing photo references and the unique slug +// index are preserved across renames. +func (m *Camera) UpdateMakeModel(makeName, modelName string) error { + if m.ID == 0 { + return fmt.Errorf("empty id") + } + + makeName = strings.TrimSpace(makeName) + modelName = strings.TrimSpace(modelName) + + if makeName == "" || modelName == "" { + return fmt.Errorf("make and model must not be empty") + } + + cam := NewCamera(makeName, modelName) + // Override the changeable fields. + m.CameraMake = cam.CameraMake + m.CameraModel = cam.CameraModel + m.CameraName = cam.CameraName + m.CameraType = cam.CameraType + + cameraMutex.Lock() + defer cameraMutex.Unlock() + if err := Db().Save(m).Error; err != nil { + return err + } else { + if !m.Unknown() { + event.EntitiesUpdated("cameras", []*Camera{m}) + + event.Publish("count.cameras", event.Data{ + "count": 1, + }) + } + cameraCache.SetDefault(m.CameraSlug, m) + } + return nil +} + +// SaveForm validates the form, copies its data into the camera, and persists it. +func (m *Camera) SaveForm(f *form.Camera) error { + if f == nil { + return fmt.Errorf("form is nil") + } else if err := f.Validate(); err != nil { + return err + } + + if err := deepcopier.Copy(m).From(f); err != nil { + return err + } + + return m.UpdateMakeModel(f.CameraMake, f.CameraModel) +} diff --git a/internal/entity/camera_cache.go b/internal/entity/camera_cache.go index 014da7c95..f31f2c5b4 100644 --- a/internal/entity/camera_cache.go +++ b/internal/entity/camera_cache.go @@ -8,6 +8,7 @@ import ( var cameraCache = gc.New(time.Hour, 15*time.Minute) +// FlushCameraCache removes all cached cameras. func FlushCameraCache() { cameraCache.Flush() } diff --git a/internal/entity/camera_makes.go b/internal/entity/camera_makes.go index eed3161cf..cc15f88d4 100644 --- a/internal/entity/camera_makes.go +++ b/internal/entity/camera_makes.go @@ -1,5 +1,6 @@ package entity +// Camera make name constants used for normalization and device-type detection. const ( MakeNone = "" MakeAcer = "Acer" diff --git a/internal/entity/camera_models.go b/internal/entity/camera_models.go index 13c8113b0..7ea7751fb 100644 --- a/internal/entity/camera_models.go +++ b/internal/entity/camera_models.go @@ -1,5 +1,6 @@ package entity +// Camera model name constants used for normalization and device-type detection. const ( ModelNone = "" ModelUnknown = "Unknown" diff --git a/internal/entity/camera_test.go b/internal/entity/camera_test.go index cd81e6578..5b432e691 100644 --- a/internal/entity/camera_test.go +++ b/internal/entity/camera_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/form" ) func TestFirstOrCreateCamera(t *testing.T) { @@ -253,3 +255,76 @@ func TestCamera_Mobile(t *testing.T) { assert.True(t, camera.Mobile()) }) } + +func TestCamera_UpdateMakeModel(t *testing.T) { + t.Run("ExistingCamera", func(t *testing.T) { + fixture := "canon-eos-7d" + camera := NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel) + + result := FirstOrCreateCamera(camera) + + defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error) + makeName := "Pentax" + modelName := "K-1" + err := result.UpdateMakeModel(makeName, modelName) + assert.NoError(t, err) + assert.Equal(t, CameraFixtures.Get(fixture).ID, result.ID) + assert.Equal(t, CameraMakes[makeName], result.CameraMake) + assert.Equal(t, modelName, result.CameraModel) + assert.Equal(t, CameraFixtures.Get(fixture).CameraSlug, result.CameraSlug) // Slug is preserved across renames. + assert.Equal(t, CameraMakes[makeName]+" "+modelName, result.CameraName) + }) + t.Run("NewCamera", func(t *testing.T) { + setup := NewCamera("", "9 99") + camera := FirstOrCreateCamera(setup) + defer assert.NoError(t, UnscopedDb().Delete(&Camera{}, "id = ?", camera.ID).Error) + makeName := "Pentax" + modelName := "K-1" + err := camera.UpdateMakeModel(makeName, modelName) + assert.NoError(t, err) + assert.Equal(t, CameraMakes[makeName], camera.CameraMake) + assert.Equal(t, modelName, camera.CameraModel) + assert.Equal(t, "9-99", camera.CameraSlug) // Slug is preserved across renames. + assert.Equal(t, CameraMakes[makeName]+" "+modelName, camera.CameraName) + }) + t.Run("NotExistingCamera", func(t *testing.T) { + camera := NewCamera("", "9 98") + err := camera.UpdateMakeModel("Pentax", "K-3") + assert.Error(t, err) + }) + t.Run("EmptyMake", func(t *testing.T) { + camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"} + err := camera.UpdateMakeModel(" ", "EOS 7D") + assert.Error(t, err) + // The guard returns before any mutation, so existing values must be untouched. + assert.Equal(t, "Canon", camera.CameraMake) + assert.Equal(t, "EOS 7D", camera.CameraModel) + }) + t.Run("EmptyModel", func(t *testing.T) { + camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID, CameraMake: "Canon", CameraModel: "EOS 7D", CameraName: "Canon EOS 7D", CameraSlug: "canon-eos-7d"} + err := camera.UpdateMakeModel("Canon", "") + assert.Error(t, err) + assert.Equal(t, "Canon", camera.CameraMake) + assert.Equal(t, "EOS 7D", camera.CameraModel) + }) +} + +func TestCamera_SaveForm(t *testing.T) { + t.Run("Success", func(t *testing.T) { + fixture := "canon-eos-7d" + camera := FirstOrCreateCamera(NewCamera(CameraFixtures.Get(fixture).CameraMake, CameraFixtures.Get(fixture).CameraModel)) + defer assert.NoError(t, UnscopedDb().Save(CameraFixtures.Pointer(fixture)).Error) + err := camera.SaveForm(&form.Camera{CameraMake: "Pentax", CameraModel: "K-1"}) + assert.NoError(t, err) + assert.Equal(t, CameraMakes["Pentax"], camera.CameraMake) + assert.Equal(t, "K-1", camera.CameraModel) + }) + t.Run("NilForm", func(t *testing.T) { + camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID} + assert.Error(t, camera.SaveForm(nil)) + }) + t.Run("EmptyMake", func(t *testing.T) { + camera := &Camera{ID: CameraFixtures.Get("canon-eos-7d").ID} + assert.Error(t, camera.SaveForm(&form.Camera{CameraMake: "", CameraModel: "K-1"})) + }) +} diff --git a/internal/entity/lens.go b/internal/entity/lens.go index 44ab8de50..d8a077f10 100644 --- a/internal/entity/lens.go +++ b/internal/entity/lens.go @@ -39,6 +39,7 @@ func (Lens) TableName() string { return "lenses" } +// UnknownLens is the placeholder used when no lens make or model is known. var UnknownLens = Lens{ LensSlug: UnknownID, LensName: "Unknown", @@ -158,21 +159,31 @@ func (m *Lens) Unknown() bool { return m.LensSlug == "" || m.LensSlug == UnknownLens.LensSlug } -// UpdateMakeModel updates the make and model for an existing lens. Primarily intended for Pentax lens. +// UpdateMakeModel updates the make and model of an existing lens, e.g. to fix Pentax models that +// ExifTool decodes as a numeric "4 38". +// The lens slug is intentionally left unchanged so existing photo references and the unique slug +// index are preserved across renames. func (m *Lens) UpdateMakeModel(makeName, modelName string) error { if m.ID == 0 { return fmt.Errorf("empty id") } + makeName = strings.TrimSpace(makeName) + modelName = strings.TrimSpace(modelName) + + if makeName == "" || modelName == "" { + return fmt.Errorf("make and model must not be empty") + } + l := NewLens(makeName, modelName) - // Override the changeable fields + // Override the changeable fields. m.LensMake = l.LensMake m.LensModel = l.LensModel m.LensName = l.LensName lensMutex.Lock() defer lensMutex.Unlock() - if err := Db().Save(&m).Error; err != nil { + if err := Db().Save(m).Error; err != nil { return err } else { if !m.Unknown() { @@ -187,12 +198,12 @@ func (m *Lens) UpdateMakeModel(makeName, modelName string) error { return nil } -// SaveForm copies validated form data into the label and persists it. +// SaveForm validates the form, copies its data into the lens, and persists it. func (m *Lens) SaveForm(f *form.Lens) error { if f == nil { return fmt.Errorf("form is nil") - } else if f.LensMake == "" || txt.Slug(f.LensModel) == "" { - return fmt.Errorf("make and model must not be empty") + } else if err := f.Validate(); err != nil { + return err } if err := deepcopier.Copy(m).From(f); err != nil { diff --git a/internal/entity/lens_cache.go b/internal/entity/lens_cache.go index b24cde872..28ac9b2c8 100644 --- a/internal/entity/lens_cache.go +++ b/internal/entity/lens_cache.go @@ -8,6 +8,7 @@ import ( var lensCache = gc.New(time.Hour, 15*time.Minute) +// FlushLensCache removes all cached lenses. func FlushLensCache() { lensCache.Flush() } diff --git a/internal/entity/lens_test.go b/internal/entity/lens_test.go index 2c4f83f1f..d89763a7d 100644 --- a/internal/entity/lens_test.go +++ b/internal/entity/lens_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/form" ) func TestNewLens(t *testing.T) { @@ -145,4 +147,40 @@ func TestLensUpdateMakeModel(t *testing.T) { err := lens.UpdateMakeModel("Pentax", "smc PENTAX-FA 31mm F1.8 AL Limited") assert.Error(t, err) }) + t.Run("EmptyMake", func(t *testing.T) { + lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"} + err := lens.UpdateMakeModel(" ", "F380") + assert.Error(t, err) + // The guard returns before any mutation, so existing values must be untouched. + assert.Equal(t, "Apple", lens.LensMake) + assert.Equal(t, "F380", lens.LensModel) + }) + t.Run("EmptyModel", func(t *testing.T) { + lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID, LensMake: "Apple", LensModel: "F380", LensName: "Apple F380", LensSlug: "lens-f-380"} + err := lens.UpdateMakeModel("Apple", "") + assert.Error(t, err) + assert.Equal(t, "Apple", lens.LensMake) + assert.Equal(t, "F380", lens.LensModel) + }) +} + +func TestLens_SaveForm(t *testing.T) { + t.Run("Success", func(t *testing.T) { + lens := Lens{} + assert.NoError(t, UnscopedDb().First(&lens, "id = ?", LensFixtures.Get("lens-f-380").ID).Error) + defer assert.NoError(t, UnscopedDb().Save(LensFixtures.Pointer("lens-f-380")).Error) + err := lens.SaveForm(&form.Lens{LensMake: "Sigma", LensModel: "85mm F1.4"}) + assert.NoError(t, err) + assert.Equal(t, CameraMakes["Sigma"], lens.LensMake) // NewLens normalizes the make. + assert.Equal(t, "85mm F1.4", lens.LensModel) + assert.Equal(t, "lens-f-380", lens.LensSlug) // Slug is preserved across renames. + }) + t.Run("NilForm", func(t *testing.T) { + lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID} + assert.Error(t, lens.SaveForm(nil)) + }) + t.Run("EmptyMake", func(t *testing.T) { + lens := &Lens{ID: LensFixtures.Get("lens-f-380").ID} + assert.Error(t, lens.SaveForm(&form.Lens{LensMake: "", LensModel: "85mm F1.4"})) + }) } diff --git a/internal/entity/query/camera.go b/internal/entity/query/camera.go new file mode 100644 index 000000000..818efb0bb --- /dev/null +++ b/internal/entity/query/camera.go @@ -0,0 +1,35 @@ +package query + +import ( + "github.com/photoprism/photoprism/internal/entity" +) + +// FindCameraBySlug returns an existing entity if exists. +func FindCameraBySlug(slug string) *entity.Camera { + if slug == "" { + return nil + } + + c := entity.Camera{} + + if err := Db().Where("camera_slug = ?", slug).First(&c).Error; err != nil { + return nil + } + + return &c +} + +// FindCameraByID returns an existing entity if exists. +func FindCameraByID(id uint) *entity.Camera { + if id == 0 { + return nil + } + + c := entity.Camera{} + + if err := Db().Where("id = ?", id).First(&c).Error; err != nil { + return nil + } + + return &c +} diff --git a/internal/entity/query/camera_test.go b/internal/entity/query/camera_test.go new file mode 100644 index 000000000..8a320c1c6 --- /dev/null +++ b/internal/entity/query/camera_test.go @@ -0,0 +1,47 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity" +) + +func TestFindCameraBySlug(t *testing.T) { + t.Run("ExistingCamera", func(t *testing.T) { + camera := FindCameraBySlug(entity.CameraFixtures.Get("canon-eos-7d").CameraSlug) + assert.NotNil(t, camera) + if camera != nil { + assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID) + assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel) + } + }) + t.Run("NotExistingCamera", func(t *testing.T) { + camera := FindCameraBySlug("IAmNotValid") + assert.Nil(t, camera) + }) + t.Run("EmptySlug", func(t *testing.T) { + camera := FindCameraBySlug("") + assert.Nil(t, camera) + }) +} + +func TestFindCameraByID(t *testing.T) { + t.Run("ExistingCamera", func(t *testing.T) { + camera := FindCameraByID(entity.CameraFixtures.Get("canon-eos-7d").ID) + assert.NotNil(t, camera) + if camera != nil { + assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").ID, camera.ID) + assert.Equal(t, entity.CameraFixtures.Get("canon-eos-7d").CameraModel, camera.CameraModel) + } + }) + t.Run("NotExistingCamera", func(t *testing.T) { + camera := FindCameraByID(99885541348) + assert.Nil(t, camera) + }) + t.Run("ZeroID", func(t *testing.T) { + camera := FindCameraByID(0) + assert.Nil(t, camera) + }) +} diff --git a/internal/entity/query/purge.go b/internal/entity/query/purge.go index deb3f9d0e..2bc99f6b1 100644 --- a/internal/entity/query/purge.go +++ b/internal/entity/query/purge.go @@ -34,12 +34,8 @@ func PurgeOrphans() error { return err } - // Remove unused camera lenses. - if err := PurgeOrphanLenses(); err != nil { - return err - } - - return nil + // Remove unused lenses. + return PurgeOrphanLenses() } // PurgeOrphanFiles removes files without a photo from the index. diff --git a/internal/entity/search/camera_results.go b/internal/entity/search/camera_results.go new file mode 100644 index 000000000..bc5092e2c --- /dev/null +++ b/internal/entity/search/camera_results.go @@ -0,0 +1,20 @@ +package search + +import ( + "time" +) + +// Camera represents a camera search result. +type Camera struct { + ID uint `json:"ID"` + CameraSlug string `json:"Slug"` + CameraName string `json:"Name"` + CameraMake string `json:"Make"` + CameraModel string `json:"Model"` + CameraType string `json:"Type"` + CameraDescription string `json:"Description"` + CameraNotes string `json:"Notes"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt time.Time `json:"DeletedAt"` +} diff --git a/internal/entity/search/cameras.go b/internal/entity/search/cameras.go new file mode 100644 index 000000000..a7071442c --- /dev/null +++ b/internal/entity/search/cameras.go @@ -0,0 +1,57 @@ +package search + +import ( + "strings" + + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/txt" +) + +// Cameras searches cameras based on their name. +func Cameras(frm form.SearchCameras) (results []Camera, err error) { + if err = frm.ParseQueryString(); err != nil { + return results, err + } + + s := Db() + + // Base query. + s = s.Table("cameras"). + Select(`cameras.*`) + + // Filter cameras without make. + if frm.NoMake { + s = s.Where("cameras.camera_make = ''") + } + + // Limit result count. + if frm.Count > 0 && frm.Count <= MaxResults { + s = s.Limit(frm.Count).Offset(frm.Offset) + } else { + s = s.Limit(MaxResults).Offset(frm.Offset) + } + + // Set sort order. + s = s.Order(OrderExpr("cameras.camera_make ASC, cameras.camera_model ASC, cameras.camera_slug ASC", frm.Reverse)) + + if frm.ID != "" { + s = s.Where("cameras.id IN (?)", strings.Split(frm.ID, txt.Or)) + + if result := s.Scan(&results); result.Error != nil { + return results, result.Error + } + + return results, nil + } + + if frm.Query != "" { + likeString := SqlParam(frm.Query, "%", "%") + s = s.Where("cameras.camera_name LIKE ? OR cameras.camera_make LIKE ? OR cameras.camera_model LIKE ?", likeString, likeString, likeString) + } + + if result := s.Scan(&results); result.Error != nil { + return results, result.Error + } + + return results, nil +} diff --git a/internal/entity/search/cameras_test.go b/internal/entity/search/cameras_test.go new file mode 100644 index 000000000..22e64f94d --- /dev/null +++ b/internal/entity/search/cameras_test.go @@ -0,0 +1,125 @@ +package search + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" +) + +func TestCameras(t *testing.T) { + t.Run("SearchWithQuery", func(t *testing.T) { + query := form.NewCameraSearch("q:Canon") + query.Count = 1005 + result, err := Cameras(query) + + if err != nil { + t.Fatal(err) + } + + assert.LessOrEqual(t, 2, len(result)) + + for _, r := range result { + assert.IsType(t, Camera{}, r) + assert.NotEmpty(t, r.ID) + assert.NotEmpty(t, r.CameraName) + assert.NotEmpty(t, r.CameraSlug) + assert.True(t, strings.Contains(strings.ToLower(r.CameraName), "canon") || strings.Contains(strings.ToLower(r.CameraMake), "canon") || strings.Contains(strings.ToLower(r.CameraModel), "canon")) + + if fix, ok := entity.CameraFixtures[r.CameraSlug]; ok { + assert.Equal(t, fix.CameraName, r.CameraName) + assert.Equal(t, fix.CameraSlug, r.CameraSlug) + assert.Equal(t, fix.CameraMake, r.CameraMake) + assert.Equal(t, fix.CameraModel, r.CameraModel) + } + } + }) + t.Run("SearchForString", func(t *testing.T) { + query := form.NewCameraSearch("Q:EOS 5D") + query.Count = 1005 + result, err := Cameras(query) + + if err != nil { + t.Fatal(err) + } + + assert.LessOrEqual(t, 1, len(result)) + + for _, r := range result { + assert.IsType(t, Camera{}, r) + assert.NotEmpty(t, r.ID) + assert.NotEmpty(t, r.CameraName) + assert.NotEmpty(t, r.CameraSlug) + assert.NotEmpty(t, r.CameraModel) + } + }) + t.Run("SearchForNoMake", func(t *testing.T) { + query := form.NewCameraSearch("NoMake:true") + query.Count = 15 + result, err := Cameras(query) + + if err != nil { + t.Fatal(err) + } + + assert.LessOrEqual(t, 1, len(result)) + + for _, r := range result { + assert.IsType(t, Camera{}, r) + assert.NotEmpty(t, r.ID) + assert.NotEmpty(t, r.CameraName) + assert.NotEmpty(t, r.CameraSlug) + assert.Empty(t, r.CameraMake) + } + }) + t.Run("SearchWithEmptyQuery", func(t *testing.T) { + query := form.NewCameraSearch("") + result, err := Cameras(query) + + if err != nil { + t.Fatal(err) + } + assert.LessOrEqual(t, 6, len(result)) + }) + t.Run("SearchWithReverse", func(t *testing.T) { + query := form.NewCameraSearch("") + query.Reverse = true + result, err := Cameras(query) + + if err != nil { + t.Fatal(err) + } + assert.LessOrEqual(t, 6, len(result)) + }) + t.Run("SearchForNoMakeAndQueryNoResults", func(t *testing.T) { + query := form.NewCameraSearch("Canon") + query.NoMake = true + result, err := Cameras(query) + + assert.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("SearchWithInvalidQueryString", func(t *testing.T) { + query := form.NewCameraSearch("xxx:bla") + result, err := Cameras(query) + + assert.Error(t, err, "unknown filter") + assert.Empty(t, result) + }) + t.Run("SearchForId", func(t *testing.T) { + f := form.SearchCameras{ + ID: "1000000|1000001", + Count: 0, + } + + result, err := Cameras(f) + + if err != nil { + t.Fatal(err) + } + assert.Len(t, result, 2) + }) +} diff --git a/internal/form/camera.go b/internal/form/camera.go new file mode 100644 index 000000000..d1016673c --- /dev/null +++ b/internal/form/camera.go @@ -0,0 +1,39 @@ +package form + +import ( + "github.com/ulule/deepcopier" + + "github.com/photoprism/photoprism/pkg/i18n" + "github.com/photoprism/photoprism/pkg/txt" +) + +// Camera represents a camera edit form. +type Camera struct { + CameraMake string `json:"Make"` + CameraModel string `json:"Model"` +} + +// NewCamera creates a new form struct based on the interface values. +func NewCamera(m any) (*Camera, error) { + frm := &Camera{} + err := deepcopier.Copy(m).To(frm) + return frm, err +} + +// Validate returns an error if any form values are invalid. +func (frm *Camera) Validate() error { + cameraMake := txt.Clip(frm.CameraMake, txt.ClipName) + cameraModel := txt.Clip(frm.CameraModel, txt.ClipName) + + if cameraMake == "" || cameraModel == "" { + return i18n.Error(i18n.ErrInvalidName) + } + + cameraSlug := txt.Slug(cameraMake + " " + cameraModel) + + if cameraSlug == "" { + return i18n.Error(i18n.ErrInvalidName) + } + + return nil +} diff --git a/internal/form/camera_test.go b/internal/form/camera_test.go new file mode 100644 index 000000000..1bcd26a4b --- /dev/null +++ b/internal/form/camera_test.go @@ -0,0 +1,44 @@ +package form + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCamera(t *testing.T) { + t.Run("Success", func(t *testing.T) { + var c = struct { + CameraMake string + CameraModel string + }{ + CameraMake: "New Make", + CameraModel: "New Model", + } + + result, err := NewCamera(c) + + if err != nil { + t.Fatal(err) + } + + assert.IsType(t, &Camera{}, result) + assert.Equal(t, "New Make", result.CameraMake) + assert.Equal(t, "New Model", result.CameraModel) + }) +} + +func TestCamera_Validate(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + frm := &Camera{CameraMake: "Canon", CameraModel: "EOS 5D"} + assert.NoError(t, frm.Validate()) + }) + t.Run("EmptyMake", func(t *testing.T) { + frm := &Camera{CameraMake: "", CameraModel: "EOS 5D"} + assert.Error(t, frm.Validate()) + }) + t.Run("EmptyModel", func(t *testing.T) { + frm := &Camera{CameraMake: "Canon", CameraModel: ""} + assert.Error(t, frm.Validate()) + }) +} diff --git a/internal/form/search_cameras.go b/internal/form/search_cameras.go new file mode 100644 index 000000000..ab2af58d7 --- /dev/null +++ b/internal/form/search_cameras.go @@ -0,0 +1,33 @@ +package form + +// SearchCameras represents search form fields for command or "/api/v1/cameras". +type SearchCameras struct { + Query string `form:"q"` + ID string `form:"id"` + Slug string `form:"slug"` + Name string `form:"name"` + NoMake bool `form:"nomake"` + Count int `form:"count" binding:"required" serialize:"-"` + Offset int `form:"offset" serialize:"-"` + Reverse bool `form:"reverse" serialize:"-"` +} + +// GetQuery returns the current search query string. +func (f *SearchCameras) GetQuery() string { + return f.Query +} + +// SetQuery stores the raw query string. +func (f *SearchCameras) SetQuery(q string) { + f.Query = q +} + +// ParseQueryString deserializes the query string into form fields. +func (f *SearchCameras) ParseQueryString() error { + return ParseQueryString(f) +} + +// NewCameraSearch creates a SearchCameras form with the provided query. +func NewCameraSearch(query string) SearchCameras { + return SearchCameras{Query: query} +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 5c4f6f31b..943987a5e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -185,6 +185,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.GetFace(APIv1) api.UpdateFace(APIv1) + // Cameras. + api.SearchCameras(APIv1) + api.UpdateCamera(APIv1) + // Lenses. api.SearchLenses(APIv1) api.UpdateLens(APIv1) diff --git a/pkg/i18n/messages.go b/pkg/i18n/messages.go index 686ae43ab..ec9a5b2eb 100644 --- a/pkg/i18n/messages.go +++ b/pkg/i18n/messages.go @@ -21,6 +21,7 @@ const ( ErrAccountNotFound ErrUserNotFound ErrLabelNotFound + ErrCameraNotFound ErrLensNotFound ErrAlbumNotFound ErrSubjectNotFound @@ -123,6 +124,7 @@ var Messages = MessageMap{ ErrAccountNotFound: gettext("Account not found"), ErrUserNotFound: gettext("User not found"), ErrLabelNotFound: gettext("Label not found"), + ErrCameraNotFound: gettext("Camera not found"), ErrLensNotFound: gettext("Lens not found"), ErrAlbumNotFound: gettext("Album not found"), ErrSubjectNotFound: gettext("Subject not found"),