diff --git a/assets/locales/messages.pot b/assets/locales/messages.pot index 8cb55a865..7813e4fec 100644 --- a/assets/locales/messages.pot +++ b/assets/locales/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-09-02 14:05+0000\n" +"POT-Creation-Date: 2021-09-18 12:59+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,277 +17,305 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: messages.go:75 +#: messages.go:81 msgid "Unexpected error, please try again" msgstr "" -#: messages.go:76 +#: messages.go:82 msgid "Invalid request" msgstr "" -#: messages.go:77 +#: messages.go:83 msgid "Changes could not be saved" msgstr "" -#: messages.go:78 +#: messages.go:84 msgid "Could not be deleted" msgstr "" -#: messages.go:79 +#: messages.go:85 #, c-format msgid "%s already exists" msgstr "" -#: messages.go:80 messages.go:83 -msgid "Not found on server, deleted?" -msgstr "" - -#: messages.go:81 -msgid "File not found" -msgstr "" - -#: messages.go:82 -msgid "Selection not found" -msgstr "" - -#: messages.go:84 -msgid "Account not found" -msgstr "" - -#: messages.go:85 -msgid "User not found" -msgstr "" - #: messages.go:86 -msgid "Label not found" +msgid "Not found" msgstr "" #: messages.go:87 -msgid "Album not found" +msgid "File not found" msgstr "" #: messages.go:88 -msgid "Subject not found" +msgid "Selection not found" msgstr "" #: messages.go:89 -msgid "Not available in public mode" +msgid "Entity not found" msgstr "" #: messages.go:90 -msgid "not available in read-only mode" +msgid "Account not found" msgstr "" #: messages.go:91 -msgid "Please log in and try again" +msgid "User not found" msgstr "" #: messages.go:92 -msgid "Upload might be offensive" +msgid "Label not found" msgstr "" #: messages.go:93 -msgid "No items selected" +msgid "Album not found" msgstr "" #: messages.go:94 -msgid "Failed creating file, please check permissions" +msgid "Subject not found" msgstr "" #: messages.go:95 -msgid "Failed creating folder, please check permissions" +msgid "Person not found" msgstr "" #: messages.go:96 -msgid "Could not connect, please try again" +msgid "Face not found" msgstr "" #: messages.go:97 -msgid "Invalid password, please try again" +msgid "Not available in public mode" msgstr "" #: messages.go:98 -msgid "Feature disabled" +msgid "not available in read-only mode" msgstr "" #: messages.go:99 -msgid "No labels selected" +msgid "Please log in and try again" msgstr "" #: messages.go:100 -msgid "No albums selected" +msgid "Upload might be offensive" msgstr "" #: messages.go:101 -msgid "No files available for download" +msgid "No items selected" msgstr "" #: messages.go:102 -msgid "Failed to create zip file" +msgid "Failed creating file, please check permissions" msgstr "" #: messages.go:103 -msgid "Invalid credentials" +msgid "Failed creating folder, please check permissions" msgstr "" #: messages.go:104 -msgid "Invalid link" +msgid "Could not connect, please try again" +msgstr "" + +#: messages.go:105 +msgid "Invalid password, please try again" +msgstr "" + +#: messages.go:106 +msgid "Feature disabled" msgstr "" #: messages.go:107 -msgid "Changes successfully saved" +msgid "No labels selected" msgstr "" #: messages.go:108 -msgid "Album created" +msgid "No albums selected" msgstr "" #: messages.go:109 -msgid "Album saved" +msgid "No files available for download" msgstr "" #: messages.go:110 -#, c-format -msgid "Album %s deleted" +msgid "Failed to create zip file" msgstr "" #: messages.go:111 -msgid "Album contents cloned" +msgid "Invalid credentials" msgstr "" #: messages.go:112 -msgid "File removed from stack" -msgstr "" - -#: messages.go:113 -msgid "File deleted" -msgstr "" - -#: messages.go:114 -#, c-format -msgid "Selection added to %s" +msgid "Invalid link" msgstr "" #: messages.go:115 -#, c-format -msgid "One entry added to %s" +msgid "Changes successfully saved" msgstr "" #: messages.go:116 -#, c-format -msgid "%d entries added to %s" +msgid "Album created" msgstr "" #: messages.go:117 -#, c-format -msgid "One entry removed from %s" +msgid "Album saved" msgstr "" #: messages.go:118 #, c-format -msgid "%d entries removed from %s" +msgid "Album %s deleted" msgstr "" #: messages.go:119 -msgid "Account created" +msgid "Album contents cloned" msgstr "" #: messages.go:120 -msgid "Account saved" +msgid "File removed from stack" msgstr "" #: messages.go:121 -msgid "Account deleted" +msgid "File deleted" msgstr "" #: messages.go:122 -msgid "Settings saved" +#, c-format +msgid "Selection added to %s" msgstr "" #: messages.go:123 -msgid "Password changed" +#, c-format +msgid "One entry added to %s" msgstr "" #: messages.go:124 #, c-format -msgid "Import completed in %d s" +msgid "%d entries added to %s" msgstr "" #: messages.go:125 -msgid "Import canceled" +#, c-format +msgid "One entry removed from %s" msgstr "" #: messages.go:126 #, c-format -msgid "Indexing completed in %d s" +msgid "%d entries removed from %s" msgstr "" #: messages.go:127 -msgid "Indexing originals..." +msgid "Account created" msgstr "" #: messages.go:128 -#, c-format -msgid "Indexing files in %s" +msgid "Account saved" msgstr "" #: messages.go:129 -msgid "Indexing canceled" +msgid "Account deleted" msgstr "" #: messages.go:130 -#, c-format -msgid "Removed %d files and %d photos" +msgid "Settings saved" msgstr "" #: messages.go:131 -#, c-format -msgid "Moving files from %s" +msgid "Password changed" msgstr "" #: messages.go:132 #, c-format -msgid "Copying files from %s" +msgid "Import completed in %d s" msgstr "" #: messages.go:133 -msgid "Labels deleted" +msgid "Import canceled" msgstr "" #: messages.go:134 -msgid "Label saved" +#, c-format +msgid "Indexing completed in %d s" msgstr "" #: messages.go:135 +msgid "Indexing originals..." +msgstr "" + +#: messages.go:136 +#, c-format +msgid "Indexing files in %s" +msgstr "" + +#: messages.go:137 +msgid "Indexing canceled" +msgstr "" + +#: messages.go:138 +#, c-format +msgid "Removed %d files and %d photos" +msgstr "" + +#: messages.go:139 +#, c-format +msgid "Moving files from %s" +msgstr "" + +#: messages.go:140 +#, c-format +msgid "Copying files from %s" +msgstr "" + +#: messages.go:141 +msgid "Labels deleted" +msgstr "" + +#: messages.go:142 +msgid "Label saved" +msgstr "" + +#: messages.go:143 +msgid "Subject saved" +msgstr "" + +#: messages.go:144 +msgid "Subject deleted" +msgstr "" + +#: messages.go:145 +msgid "Person saved" +msgstr "" + +#: messages.go:146 +msgid "Person deleted" +msgstr "" + +#: messages.go:147 #, c-format msgid "%d files uploaded in %d s" msgstr "" -#: messages.go:136 +#: messages.go:148 msgid "Selection approved" msgstr "" -#: messages.go:137 +#: messages.go:149 msgid "Selection archived" msgstr "" -#: messages.go:138 +#: messages.go:150 msgid "Selection restored" msgstr "" -#: messages.go:139 +#: messages.go:151 msgid "Selection marked as private" msgstr "" -#: messages.go:140 +#: messages.go:152 msgid "Albums deleted" msgstr "" -#: messages.go:141 +#: messages.go:153 #, c-format msgid "Zip created in %d s" msgstr "" -#: messages.go:142 +#: messages.go:154 msgid "Permanently deleted" msgstr "" diff --git a/frontend/src/model/face.js b/frontend/src/model/face.js new file mode 100644 index 000000000..db6d816ef --- /dev/null +++ b/frontend/src/model/face.js @@ -0,0 +1,108 @@ +/* + +Copyright (c) 2018 - 2021 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required + to describe our software, run your own server, for educational purposes, but not for + offering commercial goods, products, or services without prior written permission. + In other words, please ask. + +Feel free to send an e-mail to hello@photoprism.org if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: +https://docs.photoprism.org/developer-guide/ + +*/ + +import RestModel from "model/rest"; +import { DateTime } from "luxon"; +import { config } from "../session"; +import { $gettext } from "common/vm"; + +export class Face extends RestModel { + getDefaults() { + return { + ID: "", + Src: "", + SubjUID: "", + Samples: 0, + SampleRadius: 0.0, + Collisions: 0, + CollisionRadius: 0.0, + Marker: null, + MatchedAt: "", + CreatedAt: "", + UpdatedAt: "", + }; + } + + route(view) { + return { name: view, query: { q: "face:" + this.ID } }; + } + + classes(selected) { + let classes = ["is-face", "uid-" + this.UID]; + + if (selected) classes.push("is-selected"); + + return classes; + } + + getEntityName() { + return this.ID; + } + + getTitle() { + return this.Name; + } + + thumbnailUrl(size) { + if (!this.Marker || !this.Marker.FileHash) { + return `${config.contentUri}/svg/portrait`; + } + + if (!size) { + size = "tile_160"; + } + + if (this.Marker.CropArea && (size === "tile_160" || size === "tile_320")) { + return `${config.contentUri}/t/${this.Marker.FileHash}/${config.previewToken()}/${size}/${ + this.Marker.CropArea + }`; + } else { + return `${config.contentUri}/t/${this.Marker.FileHash}/${config.previewToken()}/${size}`; + } + } + + getDateString() { + return DateTime.fromISO(this.CreatedAt).toLocaleString(DateTime.DATETIME_MED); + } + + static batchSize() { + return 60; + } + + static getCollectionResource() { + return "faces"; + } + + static getModelName() { + return $gettext("Face"); + } +} + +export default Face; diff --git a/frontend/src/pages/subjects.vue b/frontend/src/pages/subjects.vue index d6e4c0620..d029ff909 100644 --- a/frontend/src/pages/subjects.vue +++ b/frontend/src/pages/subjects.vue @@ -153,7 +153,7 @@ import Event from "pubsub-js"; import RestModel from "model/rest"; import {MaxItems} from "common/clipboard"; import Notify from "common/notify"; -import {Input, InputInvalid, ClickShort, ClickLong} from "common/input"; +import {ClickLong, ClickShort, Input, InputInvalid} from "common/input"; export default { name: 'PPageSubjects', diff --git a/internal/api/account.go b/internal/api/account.go index ee70de8c2..4fdfcdeaa 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -15,6 +15,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/workers" "github.com/photoprism/photoprism/pkg/fs" @@ -25,8 +26,10 @@ const ( accountFolder = "account-folder" ) +// SearchAccounts finds accounts and returns them as JSON. +// // GET /api/v1/accounts -func GetAccounts(router *gin.RouterGroup) { +func SearchAccounts(router *gin.RouterGroup) { router.GET("/accounts", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionSearch) @@ -51,7 +54,7 @@ func GetAccounts(router *gin.RouterGroup) { return } - result, err := query.AccountSearch(f) + result, err := search.Accounts(f) if err != nil { AbortBadRequest(c) @@ -66,6 +69,8 @@ func GetAccounts(router *gin.RouterGroup) { }) } +// GetAccount returns an account as JSON. +// // GET /api/v1/accounts/:id // // Parameters: @@ -96,6 +101,8 @@ func GetAccount(router *gin.RouterGroup) { }) } +// GetAccountFolders returns folders that belong to an account as JSON. +// // GET /api/v1/accounts/:id/folders // // Parameters: diff --git a/internal/api/account_test.go b/internal/api/account_test.go index 987153f58..9a822d109 100644 --- a/internal/api/account_test.go +++ b/internal/api/account_test.go @@ -10,10 +10,10 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetAccounts(t *testing.T) { +func TestSearchAccounts(t *testing.T) { t.Run("successful request", func(t *testing.T) { app, router, _ := NewApiTest() - GetAccounts(router) + SearchAccounts(router) sess := AuthenticateAdmin(app, router) r := AuthenticatedRequest(app, "GET", "/api/v1/accounts?count=10", sess) val := gjson.Get(r.Body.String(), "#(AccName=\"Test Account\").AccURL") @@ -24,7 +24,7 @@ func TestGetAccounts(t *testing.T) { }) t.Run("invalid request", func(t *testing.T) { app, router, _ := NewApiTest() - GetAccounts(router) + SearchAccounts(router) r := PerformRequest(app, "GET", "/api/v1/accounts?xxx=10") assert.Equal(t, http.StatusBadRequest, r.Code) }) diff --git a/internal/api/album.go b/internal/api/album.go index bcbc62da9..c3bbdb851 100644 --- a/internal/api/album.go +++ b/internal/api/album.go @@ -17,6 +17,7 @@ import ( "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" @@ -41,7 +42,7 @@ func SaveAlbumAsYaml(a entity.Album) { } // GET /api/v1/albums -func GetAlbums(router *gin.RouterGroup) { +func SearchAlbums(router *gin.RouterGroup) { router.GET("/albums", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionSearch) @@ -61,10 +62,10 @@ func GetAlbums(router *gin.RouterGroup) { // Guest permissions are limited to shared albums. if s.Guest() { - f.ID = s.Shares.Join(query.Or) + f.ID = s.Shares.Join(txt.Or) } - result, err := query.AlbumSearch(f) + result, err := search.Albums(f) if err != nil { c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) @@ -334,7 +335,7 @@ func CloneAlbums(router *gin.RouterGroup) { continue } - photos, err := query.AlbumPhotos(cloneAlbum, 10000) + photos, err := search.AlbumPhotos(cloneAlbum, 10000) if err != nil { log.Errorf("album: %s", err) @@ -474,7 +475,7 @@ func DownloadAlbum(router *gin.RouterGroup) { return } - files, err := query.AlbumPhotos(a, 10000) + files, err := search.AlbumPhotos(a, 10000) if err != nil { AbortEntityNotFound(c) diff --git a/internal/api/album_test.go b/internal/api/album_test.go index 4c24fb79a..78a5b12ff 100644 --- a/internal/api/album_test.go +++ b/internal/api/album_test.go @@ -5,15 +5,16 @@ import ( "testing" "github.com/photoprism/photoprism/internal/i18n" + "github.com/tidwall/gjson" "github.com/stretchr/testify/assert" ) -func TestGetAlbums(t *testing.T) { +func TestSearchAlbums(t *testing.T) { t.Run("successful request", func(t *testing.T) { app, router, _ := NewApiTest() - GetAlbums(router) + SearchAlbums(router) r := PerformRequest(app, "GET", "/api/v1/albums?count=10") count := gjson.Get(r.Body.String(), "#") assert.LessOrEqual(t, int64(3), count.Int()) @@ -21,7 +22,7 @@ func TestGetAlbums(t *testing.T) { }) t.Run("invalid request", func(t *testing.T) { app, router, _ := NewApiTest() - GetAlbums(router) + SearchAlbums(router) r := PerformRequest(app, "GET", "/api/v1/albums?xxx=10") assert.Equal(t, http.StatusBadRequest, r.Code) }) @@ -112,7 +113,7 @@ func TestDeleteAlbum(t *testing.T) { assert.Equal(t, http.StatusOK, r.Code) val := gjson.Get(r.Body.String(), "Slug") assert.Equal(t, "delete", val.String()) - GetAlbums(router) + SearchAlbums(router) r2 := PerformRequest(app, "GET", "/api/v1/albums/"+uid) assert.Equal(t, http.StatusNotFound, r2.Code) }) diff --git a/internal/api/batch_test.go b/internal/api/batch_test.go index 935e3f10d..5211e192e 100644 --- a/internal/api/batch_test.go +++ b/internal/api/batch_test.go @@ -180,7 +180,7 @@ func TestBatchLabelsDelete(t *testing.T) { app, router, _ := NewApiTest() // Register routes. - GetLabels(router) + SearchLabels(router) BatchLabelsDelete(router) r := PerformRequest(app, "GET", "/api/v1/labels?count=15") diff --git a/internal/api/event.go b/internal/api/event.go index 3fd3439d2..3179c7c23 100644 --- a/internal/api/event.go +++ b/internal/api/event.go @@ -4,7 +4,7 @@ import ( "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" ) type EntityEvent string @@ -17,7 +17,7 @@ const ( func PublishPhotoEvent(e EntityEvent, uid string, c *gin.Context) { f := form.PhotoSearch{ID: uid, Merged: true} - result, _, err := query.PhotoSearch(f) + result, _, err := search.Photos(f) if err != nil { log.Error(err) @@ -30,7 +30,7 @@ func PublishPhotoEvent(e EntityEvent, uid string, c *gin.Context) { func PublishAlbumEvent(e EntityEvent, uid string, c *gin.Context) { f := form.AlbumSearch{ID: uid} - result, err := query.AlbumSearch(f) + result, err := search.Albums(f) if err != nil { log.Error(err) @@ -43,7 +43,7 @@ func PublishAlbumEvent(e EntityEvent, uid string, c *gin.Context) { func PublishLabelEvent(e EntityEvent, uid string, c *gin.Context) { f := form.LabelSearch{ID: uid} - result, err := query.Labels(f) + result, err := search.Labels(f) if err != nil { log.Error(err) @@ -56,7 +56,7 @@ func PublishLabelEvent(e EntityEvent, uid string, c *gin.Context) { func PublishSubjectEvent(e EntityEvent, uid string, c *gin.Context) { f := form.SubjectSearch{ID: uid} - result, err := query.SubjectSearch(f) + result, err := search.Subjects(f) if err != nil { log.Error(err) diff --git a/internal/api/face.go b/internal/api/face.go new file mode 100644 index 000000000..0e8f5dc6e --- /dev/null +++ b/internal/api/face.go @@ -0,0 +1,113 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/i18n" + "github.com/photoprism/photoprism/internal/search" + "github.com/photoprism/photoprism/pkg/txt" +) + +// SearchFaces finds and returns faces as JSON. +// +// GET /api/v1/faces +func SearchFaces(router *gin.RouterGroup) { + router.GET("/faces", func(c *gin.Context) { + s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionSearch) + + if s.Invalid() { + AbortUnauthorized(c) + return + } + + var f form.FaceSearch + + err := c.MustBindWith(&f, binding.Form) + + if err != nil { + AbortBadRequest(c) + return + } + + result, err := search.Faces(f) + + if err != nil { + c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + return + } + + AddCountHeader(c, len(result)) + AddLimitHeader(c, f.Count) + AddOffsetHeader(c, f.Offset) + AddTokenHeaders(c) + + c.JSON(http.StatusOK, result) + }) +} + +// GetFace returns a face as JSON. +// +// GET /api/v1/faces/:id +func GetFace(router *gin.RouterGroup) { + router.GET("/faces/:id", func(c *gin.Context) { + s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionRead) + + if s.Invalid() { + AbortUnauthorized(c) + return + } + + f := form.FaceSearch{ID: c.Param("id"), Markers: true} + + if results, err := search.Faces(f); err != nil || len(results) < 1 { + Abort(c, http.StatusNotFound, i18n.ErrFaceNotFound) + return + } else { + c.JSON(http.StatusOK, results[0]) + } + }) +} + +// UpdateFace updates face properties. +// +// PUT /api/v1/faces/:id +func UpdateFace(router *gin.RouterGroup) { + router.PUT("/faces/:id", func(c *gin.Context) { + s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionUpdate) + + if s.Invalid() { + AbortUnauthorized(c) + return + } + + var f form.Face + + if err := c.BindJSON(&f); err != nil { + AbortBadRequest(c) + return + } + + faceId := c.Param("id") + m := entity.FindFace(faceId) + + if m == nil { + Abort(c, http.StatusNotFound, i18n.ErrFaceNotFound) + return + } + + if err := m.SetSubjectUID(f.SubjUID); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + return + } + + event.SuccessMsg(i18n.MsgPersonSaved) + + c.JSON(http.StatusOK, m) + }) +} diff --git a/internal/api/face_test.go b/internal/api/face_test.go new file mode 100644 index 000000000..e9baa2378 --- /dev/null +++ b/internal/api/face_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/tidwall/gjson" + + "github.com/stretchr/testify/assert" +) + +func TestSearchFaces(t *testing.T) { + t.Run("Success", func(t *testing.T) { + app, router, _ := NewApiTest() + SearchFaces(router) + r := PerformRequest(app, "GET", "/api/v1/faces?count=15") + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(4), count.Int()) + assert.Equal(t, http.StatusOK, r.Code) + }) + t.Run("InvalidRequest", func(t *testing.T) { + app, router, _ := NewApiTest() + SearchFaces(router) + r := PerformRequest(app, "GET", "/api/v1/faces?xxx=15") + assert.Equal(t, http.StatusBadRequest, r.Code) + }) +} + +func TestGetFace(t *testing.T) { + t.Run("Success", func(t *testing.T) { + app, router, _ := NewApiTest() + GetFace(router) + // Example: + // {"ID":"PN6QO5INYTUSAATOFL43LL2ABAV5ACZK","Src":"","SubjUID":"jqu0xs11qekk9jx8","Samples":5,"SampleRadius":0.8,"Collisions":1,"CollisionRadius":0,"MatchedAt":null,"CreatedAt":"2021-09-18T12:06:39Z","UpdatedAt":"2021-09-18T12:06:39Z"} + r := PerformRequest(app, "GET", "/api/v1/faces/TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ") + t.Logf("GET /api/v1/faces/TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ: %s", r.Body.String()) + val := gjson.Get(r.Body.String(), "ID") + assert.Equal(t, "TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ", val.String()) + val2 := gjson.Get(r.Body.String(), "Samples") + assert.LessOrEqual(t, int64(4), val2.Int()) + assert.Equal(t, http.StatusOK, r.Code) + }) + + t.Run("Lowercase", func(t *testing.T) { + app, router, _ := NewApiTest() + GetFace(router) + r := PerformRequest(app, "GET", "/api/v1/faces/PN6QO5INYTUSAATOFL43LL2ABAV5ACzk") + val := gjson.Get(r.Body.String(), "ID") + assert.Equal(t, "PN6QO5INYTUSAATOFL43LL2ABAV5ACZK", val.String()) + val2 := gjson.Get(r.Body.String(), "SubjUID") + assert.Equal(t, "jqu0xs11qekk9jx8", val2.String()) + assert.Equal(t, http.StatusOK, r.Code) + }) + + t.Run("NotFound", func(t *testing.T) { + app, router, _ := NewApiTest() + GetFace(router) + r := PerformRequestWithBody(app, "GET", "/api/v1/faces/xxx", `{"Name": "Updated01", "Priority": 4, "Uncertainty": 80}`) + val := gjson.Get(r.Body.String(), "error") + assert.Equal(t, "Face not found", val.String()) + assert.Equal(t, http.StatusNotFound, r.Code) + }) +} + +func TestUpdateFace(t *testing.T) { + t.Run("Success", func(t *testing.T) { + app, router, _ := NewApiTest() + UpdateFace(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/faces/PN6QO5INYTUSAATOFL43LL2ABAV5ACzk", `{"SubjUID": "jqu0xs11qekk9jx8"}`) + t.Logf("PUT /api/v1/faces/PN6QO5INYTUSAATOFL43LL2ABAV5ACzk: %s", r.Body.String()) + val := gjson.Get(r.Body.String(), "ID") + assert.Equal(t, "PN6QO5INYTUSAATOFL43LL2ABAV5ACZK", val.String()) + val2 := gjson.Get(r.Body.String(), "SubjUID") + assert.Equal(t, "jqu0xs11qekk9jx8", val2.String()) + assert.Equal(t, http.StatusOK, r.Code) + }) + + t.Run("NotFound", func(t *testing.T) { + app, router, _ := NewApiTest() + UpdateFace(router) + r := PerformRequestWithBody(app, "PUT", "/api/v1/faces/xxx", `{"Name": "Updated01", "Priority": 4, "Uncertainty": 80}`) + val := gjson.Get(r.Body.String(), "error") + assert.Equal(t, "Face not found", val.String()) + assert.Equal(t, http.StatusNotFound, r.Code) + }) +} diff --git a/internal/api/label.go b/internal/api/label.go index a1c81a489..5eede4f8e 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -11,13 +11,14 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/pkg/txt" ) -// GetLabels finds and returns labels as JSON. +// SearchLabels finds and returns labels as JSON. // // GET /api/v1/labels -func GetLabels(router *gin.RouterGroup) { +func SearchLabels(router *gin.RouterGroup) { router.GET("/labels", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionSearch) @@ -35,7 +36,7 @@ func GetLabels(router *gin.RouterGroup) { return } - result, err := query.Labels(f) + result, err := search.Labels(f) if err != nil { c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) diff --git a/internal/api/label_test.go b/internal/api/label_test.go index 3930a388a..e29ed1577 100644 --- a/internal/api/label_test.go +++ b/internal/api/label_test.go @@ -9,10 +9,10 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetLabels(t *testing.T) { +func TestSearchLabels(t *testing.T) { t.Run("successful request", func(t *testing.T) { app, router, _ := NewApiTest() - GetLabels(router) + SearchLabels(router) r := PerformRequest(app, "GET", "/api/v1/labels?count=15") count := gjson.Get(r.Body.String(), "#") assert.LessOrEqual(t, int64(4), count.Int()) @@ -20,7 +20,7 @@ func TestGetLabels(t *testing.T) { }) t.Run("invalid request", func(t *testing.T) { app, router, _ := NewApiTest() - GetLabels(router) + SearchLabels(router) r := PerformRequest(app, "GET", "/api/v1/labels?xxx=15") assert.Equal(t, http.StatusBadRequest, r.Code) }) @@ -66,7 +66,7 @@ func TestLikeLabel(t *testing.T) { app, router, _ := NewApiTest() // Register routes. - GetLabels(router) + SearchLabels(router) LikeLabel(router) r2 := PerformRequest(app, "GET", "/api/v1/labels?count=3&q=like-label") @@ -106,7 +106,7 @@ func TestDislikeLabel(t *testing.T) { app, router, _ := NewApiTest() // Register routes. - GetLabels(router) + SearchLabels(router) DislikeLabel(router) r2 := PerformRequest(app, "GET", "/api/v1/labels?count=3&q=landscape") diff --git a/internal/api/photo_search.go b/internal/api/photo_search.go index e73a21b61..43016dbf3 100644 --- a/internal/api/photo_search.go +++ b/internal/api/photo_search.go @@ -7,10 +7,10 @@ import ( "github.com/gin-gonic/gin/binding" "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" ) -// GetPhotos searches the pictures index and returns the result as JSON. +// SearchPhotos searches the pictures index and returns the result as JSON. // // GET /api/v1/photos // @@ -26,7 +26,7 @@ import ( // before: date Find photos taken before (format: "2006-01-02") // after: date Find photos taken after (format: "2006-01-02") // favorite: bool Find favorites only -func GetPhotos(router *gin.RouterGroup) { +func SearchPhotos(router *gin.RouterGroup) { router.GET("/photos", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch) @@ -58,7 +58,7 @@ func GetPhotos(router *gin.RouterGroup) { f.Review = false } - result, count, err := query.PhotoSearch(f) + result, count, err := search.Photos(f) if err != nil { log.Warnf("search: %s", err) diff --git a/internal/api/geo.go b/internal/api/photo_search_geo.go similarity index 89% rename from internal/api/geo.go rename to internal/api/photo_search_geo.go index 811c582a7..cb2f9882a 100644 --- a/internal/api/geo.go +++ b/internal/api/photo_search_geo.go @@ -5,20 +5,20 @@ import ( "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/pkg/txt" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" - "github.com/photoprism/photoprism/internal/form" geojson "github.com/paulmach/go.geojson" ) -// GetGeo performs a geo search with reduced metadata details for improved performance. +// SearchPhotosGeo performs a geo search with reduced metadata details for improved performance. // // GET /api/v1/geo -func GetGeo(router *gin.RouterGroup) { +func SearchPhotosGeo(router *gin.RouterGroup) { router.GET("/geo", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch) @@ -36,7 +36,7 @@ func GetGeo(router *gin.RouterGroup) { return } - photos, err := query.Geo(f) + photos, err := search.PhotosGeo(f) if err != nil { log.Warnf("search: %s", err) diff --git a/internal/api/geo_test.go b/internal/api/photo_search_geo_test.go similarity index 68% rename from internal/api/geo_test.go rename to internal/api/photo_search_geo_test.go index b65539f68..08f529b27 100644 --- a/internal/api/geo_test.go +++ b/internal/api/photo_search_geo_test.go @@ -7,11 +7,11 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetGeo(t *testing.T) { - t.Run("get geo", func(t *testing.T) { +func TestSearchPhotosGeo(t *testing.T) { + t.Run("Success", func(t *testing.T) { app, router, _ := NewApiTest() - GetGeo(router) + SearchPhotosGeo(router) result := PerformRequest(app, "GET", "/api/v1/geo") assert.Equal(t, http.StatusOK, result.Code) diff --git a/internal/api/photo_search_test.go b/internal/api/photo_search_test.go index 8d0b8ba2f..f9a836ef5 100644 --- a/internal/api/photo_search_test.go +++ b/internal/api/photo_search_test.go @@ -9,20 +9,19 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetPhotos(t *testing.T) { - t.Run("successful request", func(t *testing.T) { +func TestSearchPhotos(t *testing.T) { + t.Run("Success", func(t *testing.T) { app, router, _ := NewApiTest() - - GetPhotos(router) + SearchPhotos(router) r := PerformRequest(app, "GET", "/api/v1/photos?count=10") count := gjson.Get(r.Body.String(), "#") assert.LessOrEqual(t, int64(2), count.Int()) assert.Equal(t, http.StatusOK, r.Code) }) - t.Run("invalid request", func(t *testing.T) { + t.Run("InvalidRequest", func(t *testing.T) { app, router, _ := NewApiTest() - GetPhotos(router) + SearchPhotos(router) result := PerformRequest(app, "GET", "/api/v1/photos?xxx=10") assert.Equal(t, http.StatusBadRequest, result.Code) }) diff --git a/internal/api/share_preview.go b/internal/api/share_preview.go index 93eba1b0a..2a532577e 100644 --- a/internal/api/share_preview.go +++ b/internal/api/share_preview.go @@ -14,7 +14,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/photoprism" - "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/fs" @@ -77,7 +77,7 @@ func SharePreview(router *gin.RouterGroup) { f.Count = 12 f.Order = "relevance" - p, count, err := query.PhotoSearch(f) + p, count, err := search.Photos(f) if err != nil { log.Error(err) diff --git a/internal/api/subject.go b/internal/api/subject.go index 5295cfc40..d8cf0242b 100644 --- a/internal/api/subject.go +++ b/internal/api/subject.go @@ -10,14 +10,14 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/i18n" - "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/pkg/txt" ) -// GetSubjects finds and returns subjects as JSON. +// SearchSubjects finds and returns subjects as JSON. // // GET /api/v1/subjects -func GetSubjects(router *gin.RouterGroup) { +func SearchSubjects(router *gin.RouterGroup) { router.GET("/subjects", func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionSearch) @@ -35,7 +35,7 @@ func GetSubjects(router *gin.RouterGroup) { return } - result, err := query.SubjectSearch(f) + result, err := search.Subjects(f) if err != nil { c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) @@ -104,7 +104,11 @@ func UpdateSubject(router *gin.RouterGroup) { return } - event.SuccessMsg(i18n.MsgSubjectSaved) + if m.IsPerson() { + event.SuccessMsg(i18n.MsgPersonSaved) + } else { + event.SuccessMsg(i18n.MsgSubjectSaved) + } c.JSON(http.StatusOK, m) }) diff --git a/internal/api/subject_test.go b/internal/api/subject_test.go index 89f9c9348..29754b4a2 100644 --- a/internal/api/subject_test.go +++ b/internal/api/subject_test.go @@ -9,25 +9,25 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetSubjects(t *testing.T) { - t.Run("successful request", func(t *testing.T) { +func TestSearchSubjects(t *testing.T) { + t.Run("Success", func(t *testing.T) { app, router, _ := NewApiTest() - GetSubjects(router) + SearchSubjects(router) r := PerformRequest(app, "GET", "/api/v1/subjects?count=10") count := gjson.Get(r.Body.String(), "#") assert.LessOrEqual(t, int64(3), count.Int()) assert.Equal(t, http.StatusOK, r.Code) }) - t.Run("invalid request", func(t *testing.T) { + t.Run("InvalidRequest", func(t *testing.T) { app, router, _ := NewApiTest() - GetSubjects(router) + SearchSubjects(router) r := PerformRequest(app, "GET", "/api/v1/subjects?xxx=10") assert.Equal(t, http.StatusBadRequest, r.Code) }) } func TestGetSubject(t *testing.T) { - t.Run("successful request", func(t *testing.T) { + t.Run("Success", func(t *testing.T) { app, router, _ := NewApiTest() GetSubject(router) r := PerformRequest(app, "GET", "/api/v1/subjects/jqy1y111h1njaaaa") @@ -35,7 +35,7 @@ func TestGetSubject(t *testing.T) { assert.Equal(t, "dangling-subject", val.String()) assert.Equal(t, http.StatusOK, r.Code) }) - t.Run("invalid request", func(t *testing.T) { + t.Run("InvalidRequest", func(t *testing.T) { app, router, _ := NewApiTest() GetSubject(router) r := PerformRequest(app, "GET", "/api/v1/subjects/xxx1y111h1njaaaa") @@ -46,13 +46,13 @@ func TestGetSubject(t *testing.T) { } func TestLikeSubject(t *testing.T) { - t.Run("like not existing subject", func(t *testing.T) { + t.Run("InvalidSubject", func(t *testing.T) { app, router, _ := NewApiTest() LikeSubject(router) r := PerformRequest(app, "POST", "/api/v1/subjects/8775789/like") assert.Equal(t, http.StatusNotFound, r.Code) }) - t.Run("like existing subject", func(t *testing.T) { + t.Run("ExistingSubject", func(t *testing.T) { app, router, _ := NewApiTest() // Register routes. @@ -79,13 +79,13 @@ func TestLikeSubject(t *testing.T) { }) } func TestDislikeSubject(t *testing.T) { - t.Run("dislike not existing subject", func(t *testing.T) { + t.Run("InvalidSubject", func(t *testing.T) { app, router, _ := NewApiTest() DislikeSubject(router) r := PerformRequest(app, "DELETE", "/api/v1/subjects/8775789/like") assert.Equal(t, http.StatusNotFound, r.Code) }) - t.Run("dislike existing subject", func(t *testing.T) { + t.Run("ExistingSubject", func(t *testing.T) { app, router, _ := NewApiTest() // Register routes. diff --git a/internal/entity/face.go b/internal/entity/face.go index f52024a73..0c6c81b7a 100644 --- a/internal/entity/face.go +++ b/internal/entity/face.go @@ -5,6 +5,7 @@ import ( "encoding/base32" "encoding/json" "fmt" + "strings" "sync" "time" @@ -255,6 +256,7 @@ func (m *Face) SetSubjectUID(subjUID string) (err error) { Where("face_id = ?", m.ID). Where("subj_src = ?", SrcAuto). Where("subj_uid <> ?", m.SubjUID). + Where("marker_invalid = 0"). Updates(Values{"SubjUID": m.SubjUID, "MarkerReview": false}).Error; err != nil { return err } @@ -329,14 +331,11 @@ func FindFace(id string) *Face { return nil } - result := Face{} + f := Face{} - db := Db() - db = db.Where("id = ?", id) - - if err := db.First(&result).Error; err != nil { + if err := Db().Where("id = ?", strings.ToUpper(id)).First(&f).Error; err != nil { return nil } - return &result + return &f } diff --git a/internal/entity/marker.go b/internal/entity/marker.go index 79150aa31..618d34400 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -525,6 +525,20 @@ func FindMarker(uid string) *Marker { return &result } +// FindFaceMarker finds the best marker for a given face +func FindFaceMarker(faceId string) *Marker { + var result Marker + + if err := Db().Where("face_id = ?", faceId). + Where("file_hash <> '' AND marker_invalid = 0"). + Order("q DESC").First(&result).Error; err != nil { + log.Warnf("face: no marker for %s", txt.Quote(faceId)) + return nil + } + + return &result +} + // UpdateOrCreateMarker updates a marker in the database or creates a new one if needed. func UpdateOrCreateMarker(m *Marker) (*Marker, error) { const d = 0.07 diff --git a/internal/entity/marker_fixtures.go b/internal/entity/marker_fixtures.go index f3f44b1da..543ae873e 100644 --- a/internal/entity/marker_fixtures.go +++ b/internal/entity/marker_fixtures.go @@ -27,6 +27,7 @@ var MarkerFixtures = MarkerMap{ "1000003-1": Marker{ //Photo04 MarkerUID: "mqu0xs11qekk9jx8", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", SubjUID: "jqu0xs11qekk9jx8", MarkerSrc: SrcImage, MarkerType: MarkerLabel, @@ -40,6 +41,7 @@ var MarkerFixtures = MarkerMap{ "1000003-2": Marker{ //Photo04 MarkerUID: "mt9k3pw1wowuy3c3", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", SubjUID: "lt9k3pw1wowuy3c3", FaceID: "LRG2HJBDZE66LYG7Q5SRFXO2MDTOES52", MarkerName: "Unknown", @@ -55,6 +57,7 @@ var MarkerFixtures = MarkerMap{ "1000003-3": Marker{ //Photo04 MarkerUID: "mt9k3pw1wowuy111", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", SubjUID: "", MarkerSrc: SrcImage, MarkerType: MarkerLabel, @@ -69,6 +72,7 @@ var MarkerFixtures = MarkerMap{ "1000003-4": Marker{ //Photo04 MarkerUID: "mt9k3pw1wowuy222", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", SubjUID: "", MarkerSrc: SrcImage, MarkerType: MarkerFace, @@ -85,6 +89,7 @@ var MarkerFixtures = MarkerMap{ "1000003-5": Marker{ //Photo04 MarkerUID: "mt9k3pw1wowuy333", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("unknown").ID, SubjUID: "", SubjSrc: SrcAuto, @@ -103,6 +108,7 @@ var MarkerFixtures = MarkerMap{ "1000003-6": Marker{ //Photo04 MarkerUID: "mt9k3pw1wowuy444", FileUID: "ft2es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("john-doe").ID, FaceDist: 0.2, SubjSrc: SrcAuto, @@ -122,6 +128,7 @@ var MarkerFixtures = MarkerMap{ "ma-ba-1": Marker{ //Photo27 MarkerUID: "mt9k3pw1wowuy555", FileUID: "ft2es49qhhinlple", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("fa-gr").ID, FaceDist: 0.5, SubjSrc: "", @@ -141,6 +148,7 @@ var MarkerFixtures = MarkerMap{ "fa-gr-1": Marker{ //Photo27 MarkerUID: "mt9k3pw1wowuy666", FileUID: "ft2es49qhhinlple", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("fa-gr").ID, FaceDist: 0.6, SubjSrc: SrcAuto, @@ -160,6 +168,7 @@ var MarkerFixtures = MarkerMap{ "fa-gr-2": Marker{ //Photo03 MarkerUID: "mt9k3pw1wowuy777", FileUID: "ft2es49w15bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("fa-gr").ID, FaceDist: 0.6, SubjSrc: SrcAuto, @@ -179,6 +188,7 @@ var MarkerFixtures = MarkerMap{ "fa-gr-3": Marker{ //19800101_000002_D640C559 MarkerUID: "mt9k3pw1wowuy888", FileUID: "ft8es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("fa-gr").ID, FaceDist: 0.6, SubjSrc: SrcAuto, @@ -198,6 +208,7 @@ var MarkerFixtures = MarkerMap{ "actress-a-1": Marker{ //Photo27 MarkerUID: "mt9k3pw1wowuy999", FileUID: "ft2es49qhhinlple", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actress-1").ID, CropArea: "045038063041", FaceDist: 0.26852392873736236, @@ -218,6 +229,7 @@ var MarkerFixtures = MarkerMap{ "actress-a-2": Marker{ //Photo03 - non primary file MarkerUID: "mt9k3pw1wowu1000", FileUID: "ft2es49whhbnlqdn", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actress-1").ID, CropArea: "046045043065", FaceDist: 0.4507357278575355, @@ -238,6 +250,7 @@ var MarkerFixtures = MarkerMap{ "actress-a-3": Marker{ //19800101_000002_D640C559 MarkerUID: "mt9k3pw1wowu1001", FileUID: "ft8es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actress-1").ID, CropArea: "05403304060446", FaceDist: 0.5099754448545762, @@ -258,6 +271,7 @@ var MarkerFixtures = MarkerMap{ "actor-a-1": Marker{ //Photo05 MarkerUID: "mt9k3pw1wowu1002", FileUID: "ft3es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actor-1").ID, FaceDist: 0.5223304453393212, SubjSrc: "", @@ -277,6 +291,7 @@ var MarkerFixtures = MarkerMap{ "actor-a-2": Marker{ //Photo02 MarkerUID: "mt9k3pw1wowu1003", FileUID: "ft2es39q45bnlqd0", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actor-1").ID, FaceDist: 0.5088545446490167, SubjSrc: "", @@ -296,6 +311,7 @@ var MarkerFixtures = MarkerMap{ "actor-a-3": Marker{ //Photo10 MarkerUID: "mt9k3pw1wowu1004", FileUID: "fikjs39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actor-1").ID, FaceDist: 0.3139983399779298, SubjSrc: "", @@ -315,6 +331,7 @@ var MarkerFixtures = MarkerMap{ "actor-a-4": Marker{ //19800101_000002_D640C559 MarkerUID: "mt9k3pw1wowu1005", FileUID: "ft8es39w45bnlqdw", + FileHash: "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", FaceID: FaceFixtures.Get("actor-1").ID, FaceDist: 0.3139983399779298, SubjSrc: "", diff --git a/internal/form/face.go b/internal/form/face.go new file mode 100644 index 000000000..972de1694 --- /dev/null +++ b/internal/form/face.go @@ -0,0 +1,14 @@ +package form + +import "github.com/ulule/deepcopier" + +// Face represents a face edit form. +type Face struct { + SubjUID string `json:"SubjUID"` +} + +func NewFace(m interface{}) (f Face, err error) { + err = deepcopier.Copy(m).To(&f) + + return f, err +} diff --git a/internal/form/face_search.go b/internal/form/face_search.go new file mode 100644 index 000000000..d5a3a56be --- /dev/null +++ b/internal/form/face_search.go @@ -0,0 +1,29 @@ +package form + +// FaceSearch represents search form fields for "/api/v1/faces". +type FaceSearch struct { + Query string `form:"q"` + ID string `form:"id"` + Subject string `form:"subject"` + Unknown bool `form:"unknown"` + Markers bool `form:"markers"` + Count int `form:"count" binding:"required" serialize:"-"` + Offset int `form:"offset" serialize:"-"` + Order string `form:"order" serialize:"-"` +} + +func (f *FaceSearch) GetQuery() string { + return f.Query +} + +func (f *FaceSearch) SetQuery(q string) { + f.Query = q +} + +func (f *FaceSearch) ParseQueryString() error { + return ParseQueryString(f) +} + +func NewFaceSearch(query string) FaceSearch { + return FaceSearch{Query: query} +} diff --git a/internal/form/face_test.go b/internal/form/face_test.go new file mode 100644 index 000000000..8d9539aa0 --- /dev/null +++ b/internal/form/face_test.go @@ -0,0 +1,25 @@ +package form + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFace(t *testing.T) { + t.Run("success", func(t *testing.T) { + var m = struct { + SubjUID string `json:"SubjUID"` + }{ + SubjUID: "jqzmd5q3b8o2yxu7", + } + + f, err := NewFace(m) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "jqzmd5q3b8o2yxu7", f.SubjUID) + }) +} diff --git a/internal/form/geo_search.go b/internal/form/geo_search.go index 6615c65e2..769c67b4c 100644 --- a/internal/form/geo_search.go +++ b/internal/form/geo_search.go @@ -25,6 +25,7 @@ type GeoSearch struct { S2 string `form:"s2"` Olc string `form:"olc"` Dist uint `form:"dist"` + Face string `form:"face"` // UIDs Subject string `form:"subject"` // UIDs Subjects string `form:"subjects"` // Text People string `form:"people"` // Alias for Subjects diff --git a/internal/form/photo_search.go b/internal/form/photo_search.go index 9e100f27f..9c24c3fb2 100644 --- a/internal/form/photo_search.go +++ b/internal/form/photo_search.go @@ -42,12 +42,7 @@ type PhotoSearch struct { Mono bool `form:"mono"` Portrait bool `form:"portrait"` Geo bool `form:"geo"` - Subject string `form:"subject"` // UIDs - Subjects string `form:"subjects"` // Text - People string `form:"people"` // Alias for Subjects Keywords string `form:"keywords"` - Album string `form:"album"` // UIDs - Albums string `form:"albums"` // Text Label string `form:"label"` Category string `form:"category"` // Moments Country string `form:"country"` // Moments @@ -55,6 +50,12 @@ type PhotoSearch struct { Year int `form:"year"` // Moments Month int `form:"month"` // Moments Day int `form:"day"` // Moments + Face string `form:"face"` // UIDs + Subject string `form:"subject"` // UIDs + Subjects string `form:"subjects"` // Text + People string `form:"people"` // Alias for Subjects + Album string `form:"album"` // UIDs + Albums string `form:"albums"` // Text Color string `form:"color"` Faces string `form:"faces"` // Find or exclude faces if detected. Quality int `form:"quality"` diff --git a/internal/i18n/messages.go b/internal/i18n/messages.go index 2ca429169..bc095f011 100644 --- a/internal/i18n/messages.go +++ b/internal/i18n/messages.go @@ -15,6 +15,8 @@ const ( ErrLabelNotFound ErrAlbumNotFound ErrSubjectNotFound + ErrPersonNotFound + ErrFaceNotFound ErrPublic ErrReadOnly ErrUnauthorized @@ -62,6 +64,8 @@ const ( MsgLabelSaved MsgSubjectSaved MsgSubjectDeleted + MsgPersonSaved + MsgPersonDeleted MsgFilesUploadedIn MsgSelectionApproved MsgSelectionArchived @@ -79,15 +83,17 @@ var Messages = MessageMap{ ErrSaveFailed: gettext("Changes could not be saved"), ErrDeleteFailed: gettext("Could not be deleted"), ErrAlreadyExists: gettext("%s already exists"), - ErrNotFound: gettext("Not found on server, deleted?"), + ErrNotFound: gettext("Not found"), ErrFileNotFound: gettext("File not found"), ErrSelectionNotFound: gettext("Selection not found"), - ErrEntityNotFound: gettext("Not found on server, deleted?"), + ErrEntityNotFound: gettext("Entity not found"), ErrAccountNotFound: gettext("Account not found"), ErrUserNotFound: gettext("User not found"), ErrLabelNotFound: gettext("Label not found"), ErrAlbumNotFound: gettext("Album not found"), ErrSubjectNotFound: gettext("Subject not found"), + ErrPersonNotFound: gettext("Person not found"), + ErrFaceNotFound: gettext("Face not found"), ErrPublic: gettext("Not available in public mode"), ErrReadOnly: gettext("not available in read-only mode"), ErrUnauthorized: gettext("Please log in and try again"), @@ -136,6 +142,8 @@ var Messages = MessageMap{ MsgLabelSaved: gettext("Label saved"), MsgSubjectSaved: gettext("Subject saved"), MsgSubjectDeleted: gettext("Subject deleted"), + MsgPersonSaved: gettext("Person saved"), + MsgPersonDeleted: gettext("Person deleted"), MsgFilesUploadedIn: gettext("%d files uploaded in %d s"), MsgSelectionApproved: gettext("Selection approved"), MsgSelectionArchived: gettext("Selection archived"), diff --git a/internal/photoprism/albums.go b/internal/photoprism/albums.go index 229b7ac87..540e50d39 100644 --- a/internal/photoprism/albums.go +++ b/internal/photoprism/albums.go @@ -20,7 +20,7 @@ func BackupAlbums(backupPath string, force bool) (count int, result error) { return count, nil } - albums, err := query.GetAlbums(0, 9999) + albums, err := query.Albums(0, 9999) if err != nil { return count, err @@ -54,7 +54,7 @@ func RestoreAlbums(backupPath string, force bool) (count int, result error) { return count, nil } - existing, err := query.GetAlbums(0, 1) + existing, err := query.Albums(0, 1) if err != nil { return count, err diff --git a/internal/photoprism/moments.go b/internal/photoprism/moments.go index ca84f1f29..b8cfb5244 100644 --- a/internal/photoprism/moments.go +++ b/internal/photoprism/moments.go @@ -203,7 +203,7 @@ func (w *Moments) Start() (err error) { } else { w := txt.Words(f.Label) w = append(w, mom.Label) - f.Label = strings.Join(txt.UniqueWords(w), query.Or) + f.Label = strings.Join(txt.UniqueWords(w), txt.Or) } if err := a.Update("AlbumFilter", f.Serialize()); err != nil { diff --git a/internal/query/account.go b/internal/query/account.go new file mode 100644 index 000000000..368246feb --- /dev/null +++ b/internal/query/account.go @@ -0,0 +1,14 @@ +package query + +import ( + "github.com/photoprism/photoprism/internal/entity" +) + +// AccountByID finds an account by primary key. +func AccountByID(id uint) (result entity.Account, err error) { + if err := Db().Where("id = ?", id).First(&result).Error; err != nil { + return result, err + } + + return result, nil +} diff --git a/internal/query/account_test.go b/internal/query/account_test.go new file mode 100644 index 000000000..3c0ddb991 --- /dev/null +++ b/internal/query/account_test.go @@ -0,0 +1,29 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccountByID(t *testing.T) { + t.Run("existing account", func(t *testing.T) { + r, err := AccountByID(uint(1000001)) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Test Account2", r.AccName) + + }) + t.Run("record not found", func(t *testing.T) { + r, err := AccountByID(uint(123)) + + if err == nil { + t.Fatal() + } + assert.Equal(t, "record not found", err.Error()) + assert.Empty(t, r) + }) +} diff --git a/internal/query/albums.go b/internal/query/albums.go index 084986508..228cdcf44 100644 --- a/internal/query/albums.go +++ b/internal/query/albums.go @@ -2,49 +2,19 @@ package query import ( "fmt" - "strings" - "time" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/capture" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/pkg/txt" ) -// AlbumResult contains found albums -type AlbumResult struct { - ID uint `json:"-"` - AlbumUID string `json:"UID"` - ParentUID string `json:"ParentUID"` - Thumb string `json:"Thumb"` - ThumbSrc string `json:"ThumbSrc"` - AlbumSlug string `json:"Slug"` - AlbumType string `json:"Type"` - AlbumTitle string `json:"Title"` - AlbumLocation string `json:"Location"` - AlbumCategory string `json:"Category"` - AlbumCaption string `json:"Caption"` - AlbumDescription string `json:"Description"` - AlbumNotes string `json:"Notes"` - AlbumFilter string `json:"Filter"` - AlbumOrder string `json:"Order"` - AlbumTemplate string `json:"Template"` - AlbumPath string `json:"Path"` - AlbumCountry string `json:"Country"` - AlbumYear int `json:"Year"` - AlbumMonth int `json:"Month"` - AlbumDay int `json:"Day"` - AlbumFavorite bool `json:"Favorite"` - AlbumPrivate bool `json:"Private"` - PhotoCount int `json:"PhotoCount"` - LinkCount int `json:"LinkCount"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt time.Time `json:"DeletedAt,omitempty"` +// Albums returns a slice of albums. +func Albums(offset, limit int) (results entity.Albums, err error) { + err = UnscopedDb().Table("albums").Select("*").Offset(offset).Limit(limit).Find(&results).Error + return results, err } -type AlbumResults []AlbumResult - // AlbumByUID returns a Album based on the UID. func AlbumByUID(albumUID string) (album entity.Album, err error) { if err := Db().Where("album_uid = ?", albumUID).First(&album).Error; err != nil { @@ -63,7 +33,7 @@ func AlbumCoverByUID(uid string) (file entity.File, err error) { } else if a.AlbumType != entity.AlbumDefault { // TODO: Optimize f := form.PhotoSearch{Album: a.AlbumUID, Filter: a.AlbumFilter, Order: entity.SortOrderRelevance, Count: 1, Offset: 0, Merged: false} - if photos, _, err := PhotoSearch(f); err != nil { + if photos, _, err := search.Photos(f); err != nil { return file, err } else if len(photos) > 0 { for _, photo := range photos { @@ -99,103 +69,6 @@ func AlbumCoverByUID(uid string) (file entity.File, err error) { return file, nil } -// AlbumPhotos returns up to count photos from an album. -func AlbumPhotos(a entity.Album, count int) (results PhotoResults, err error) { - results, _, err = PhotoSearch(form.PhotoSearch{ - Album: a.AlbumUID, - Filter: a.AlbumFilter, - Count: count, - Offset: 0, - }) - - return results, err -} - -// AlbumSearch searches albums based on their name. -func AlbumSearch(f form.AlbumSearch) (results AlbumResults, err error) { - if err := f.ParseQueryString(); err != nil { - return results, err - } - - defer log.Debug(capture.Time(time.Now(), fmt.Sprintf("albums: search %s", form.Serialize(f, true)))) - - // Base query. - s := UnscopedDb().Table("albums"). - Select("albums.*, cp.photo_count, cl.link_count"). - Joins("LEFT JOIN (SELECT album_uid, count(photo_uid) AS photo_count FROM photos_albums WHERE hidden = 0 AND missing = 0 GROUP BY album_uid) AS cp ON cp.album_uid = albums.album_uid"). - Joins("LEFT JOIN (SELECT share_uid, count(share_uid) AS link_count FROM links GROUP BY share_uid) AS cl ON cl.share_uid = albums.album_uid"). - Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL)"). - Where("albums.deleted_at IS NULL") - - // Limit result count. - if f.Count > 0 && f.Count <= MaxResults { - s = s.Limit(f.Count).Offset(f.Offset) - } else { - s = s.Limit(MaxResults).Offset(f.Offset) - } - - // Set sort order. - switch f.Order { - case "slug": - s = s.Order("albums.album_favorite DESC, album_slug ASC") - default: - s = s.Order("albums.album_favorite DESC, albums.album_year DESC, albums.album_month DESC, albums.album_day DESC, albums.album_title, albums.created_at DESC") - } - - if f.ID != "" { - s = s.Where("albums.album_uid IN (?)", strings.Split(f.ID, Or)) - - if result := s.Scan(&results); result.Error != nil { - return results, result.Error - } - - return results, nil - } - - if f.Query != "" { - likeString := "%" + f.Query + "%" - s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString) - } - - if f.Type != "" { - s = s.Where("albums.album_type IN (?)", strings.Split(f.Type, Or)) - } - - if f.Category != "" { - s = s.Where("albums.album_category IN (?)", strings.Split(f.Category, Or)) - } - - if f.Location != "" { - s = s.Where("albums.album_location IN (?)", strings.Split(f.Location, Or)) - } - - if f.Country != "" { - s = s.Where("albums.album_country IN (?)", strings.Split(f.Country, Or)) - } - - if f.Favorite { - s = s.Where("albums.album_favorite = 1") - } - - if (f.Year > 0 && f.Year <= txt.YearMax) || f.Year == entity.UnknownYear { - s = s.Where("albums.album_year = ?", f.Year) - } - - if (f.Month >= txt.MonthMin && f.Month <= txt.MonthMax) || f.Month == entity.UnknownMonth { - s = s.Where("albums.album_month = ?", f.Month) - } - - if (f.Day >= txt.DayMin && f.Month <= txt.DayMax) || f.Day == entity.UnknownDay { - s = s.Where("albums.album_day = ?", f.Day) - } - - if result := s.Scan(&results); result.Error != nil { - return results, result.Error - } - - return results, nil -} - // UpdateAlbumDates updates album year, month and day based on indexed photo metadata. func UpdateAlbumDates() error { switch DbDialect() { @@ -228,9 +101,3 @@ func AlbumEntryFound(uid string) error { return UnscopedDb().Exec(`UPDATE photos_albums SET missing = 0 WHERE photo_uid = ?`, uid).Error } } - -// GetAlbums returns a slice of albums. -func GetAlbums(offset, limit int) (results entity.Albums, err error) { - err = UnscopedDb().Table("albums").Select("*").Offset(offset).Limit(limit).Find(&results).Error - return results, err -} diff --git a/internal/query/albums_test.go b/internal/query/albums_test.go index 207dfd3c9..6042b62c2 100644 --- a/internal/query/albums_test.go +++ b/internal/query/albums_test.go @@ -3,8 +3,6 @@ package query import ( "testing" - "github.com/photoprism/photoprism/internal/entity" - form "github.com/photoprism/photoprism/internal/form" "github.com/stretchr/testify/assert" ) @@ -61,152 +59,6 @@ func TestAlbumCoverByUID(t *testing.T) { }) } -func TestAlbumPhotos(t *testing.T) { - t.Run("search with string", func(t *testing.T) { - results, err := AlbumPhotos(entity.AlbumFixtures.Get("april-1990"), 2) - - if err != nil { - t.Fatal(err) - } - - if len(results) < 2 { - t.Errorf("at least 2 results expected: %d", len(results)) - } - }) -} - -func TestAlbumSearch(t *testing.T) { - t.Run("search with string", func(t *testing.T) { - query := form.NewAlbumSearch("chr") - result, err := AlbumSearch(query) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "Christmas 2030", result[0].AlbumTitle) - }) - - t.Run("search with slug", func(t *testing.T) { - query := form.NewAlbumSearch("slug:holiday count:10") - result, err := AlbumSearch(query) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "Holiday 2030", result[0].AlbumTitle) - }) - - t.Run("search with country", func(t *testing.T) { - query := form.NewAlbumSearch("country:ca") - result, err := AlbumSearch(query) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "April 1990", result[0].AlbumTitle) - }) - - t.Run("favorites true", func(t *testing.T) { - query := form.NewAlbumSearch("favorite:true count:10000") - - result, err := AlbumSearch(query) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "Holiday 2030", result[0].AlbumTitle) - }) - t.Run("empty query", func(t *testing.T) { - query := form.NewAlbumSearch("order:slug") - - results, err := AlbumSearch(query) - - if err != nil { - t.Fatal(err) - } - - if len(results) < 3 { - t.Errorf("at least 3 results expected: %d", len(results)) - } - }) - t.Run("search with invalid query string", func(t *testing.T) { - query := form.NewAlbumSearch("xxx:bla") - result, err := AlbumSearch(query) - assert.Error(t, err, "unknown filter") - t.Log(result) - }) - t.Run("search with invalid query string", func(t *testing.T) { - query := form.NewAlbumSearch("xxx:bla") - result, err := AlbumSearch(query) - assert.Error(t, err, "unknown filter") - t.Log(result) - }) - t.Run("search for existing ID", func(t *testing.T) { - f := form.AlbumSearch{ - Query: "", - ID: "at9lxuqxpogaaba7", - Slug: "", - Title: "", - Favorite: false, - Count: 0, - Offset: 0, - Order: "", - } - - result, err := AlbumSearch(f) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, 1, len(result)) - assert.Equal(t, "christmas-2030", result[0].AlbumSlug) - }) - t.Run("search with multiple filters", func(t *testing.T) { - f := form.AlbumSearch{ - Query: "", - Type: "moment", - Category: "Fun", - Location: "Favorite Park", - Title: "Empty Moment", - Count: 0, - Offset: 0, - Order: "", - } - - result, err := AlbumSearch(f) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, 1, len(result)) - assert.Equal(t, "Empty Moment", result[0].AlbumTitle) - }) - t.Run("search for year/month/day", func(t *testing.T) { - f := form.AlbumSearch{ - Year: 2021, - Month: 10, - Day: 3, - Count: 0, - Offset: 0, - Order: "", - } - - result, err := AlbumSearch(f) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, 0, len(result)) - }) -} - func TestUpdateAlbumDates(t *testing.T) { t.Run("success", func(t *testing.T) { if err := UpdateAlbumDates(); err != nil { @@ -233,7 +85,7 @@ func TestAlbumEntryFound(t *testing.T) { func TestGetAlbums(t *testing.T) { t.Run("success", func(t *testing.T) { - r, err := GetAlbums(0, 3) + r, err := Albums(0, 3) if err != nil { t.Fatal(err) diff --git a/internal/query/selection.go b/internal/query/photo_selection.go similarity index 100% rename from internal/query/selection.go rename to internal/query/photo_selection.go diff --git a/internal/query/selection_test.go b/internal/query/photo_selection_test.go similarity index 100% rename from internal/query/selection_test.go rename to internal/query/photo_selection_test.go diff --git a/internal/query/query.go b/internal/query/query.go index e85c1bb5d..0816db26f 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -42,24 +42,8 @@ var log = event.Log const ( MySQL = "mysql" SQLite = "sqlite3" - Or = "|" - And = "&" - Plus = " + " - OrEn = " or " - AndEn = " and " - WithEn = " with " - InEn = " in " - AtEn = " at " - Space = " " - Empty = "" ) -// MaxResults is max result limit for queries. -const MaxResults = 10000 - -// SearchRadius is about 1 km. -const SearchRadius = 0.009 - // Cols represents a list of database columns. type Cols []string diff --git a/internal/query/subjects.go b/internal/query/subjects.go index 8078c18e6..5d53a997c 100644 --- a/internal/query/subjects.go +++ b/internal/query/subjects.go @@ -2,9 +2,7 @@ package query import ( "fmt" - "strings" - "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/internal/entity" @@ -119,60 +117,3 @@ func CreateMarkerSubjects() (affected int64, err error) { return affected, err } - -// SearchSubjUIDs finds subject UIDs matching the search string, and removes names from the remaining query. -func SearchSubjUIDs(s string) (result []string, names []string, remaining string) { - if s == "" { - return result, names, s - } - - type Matches struct { - SubjUID string - SubjName string - SubjAlias string - } - - var matches []Matches - - wheres := LikeAllNames(Cols{"subj_name", "subj_alias"}, s) - - if len(wheres) == 0 { - return result, names, s - } - - remaining = s - - for _, where := range wheres { - var subj []string - - stmt := Db().Model(entity.Subject{}) - stmt = stmt.Where("?", gorm.Expr(where)) - - if err := stmt.Scan(&matches).Error; err != nil { - log.Errorf("search: %s while finding subjects", err) - } else if len(matches) == 0 { - continue - } - - for _, m := range matches { - subj = append(subj, m.SubjUID) - names = append(names, m.SubjName) - - for _, r := range txt.Words(strings.ToLower(m.SubjName)) { - if len(r) > 1 { - remaining = strings.ReplaceAll(remaining, r, "") - } - } - - for _, r := range txt.Words(strings.ToLower(m.SubjAlias)) { - if len(r) > 1 { - remaining = strings.ReplaceAll(remaining, r, "") - } - } - } - - result = append(result, strings.Join(subj, Or)) - } - - return result, names, NormalizeSearchQuery(remaining) -} diff --git a/internal/query/subjects_test.go b/internal/query/subjects_test.go index a93df6497..53a02201e 100644 --- a/internal/query/subjects_test.go +++ b/internal/query/subjects_test.go @@ -1,7 +1,6 @@ package query import ( - "strings" "testing" "github.com/photoprism/photoprism/internal/entity" @@ -71,25 +70,3 @@ func TestCreateMarkerSubjects(t *testing.T) { assert.NoError(t, err) assert.LessOrEqual(t, int64(0), affected) } - -func TestSearchSubjUIDs(t *testing.T) { - t.Run("john & his | cats", func(t *testing.T) { - result, names, remaining := SearchSubjUIDs("john & his | cats") - - if len(result) != 1 { - t.Fatal("expected one result") - } else { - assert.Equal(t, "jqu0xs11qekk9jx8", result[0]) - assert.Equal(t, "his | cats", remaining) - assert.Equal(t, "John Doe", strings.Join(names, ", ")) - } - }) - t.Run("xxx", func(t *testing.T) { - result, _, _ := SearchSubjUIDs("xxx") - assert.Empty(t, result) - }) - t.Run("empty string", func(t *testing.T) { - result, _, _ := SearchSubjUIDs("") - assert.Empty(t, result) - }) -} diff --git a/internal/query/account_search.go b/internal/search/accounts.go similarity index 61% rename from internal/query/account_search.go rename to internal/search/accounts.go index 730be572a..a377d3ac6 100644 --- a/internal/query/account_search.go +++ b/internal/search/accounts.go @@ -1,12 +1,12 @@ -package query +package search import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" ) -// AccountSearch returns a list of accounts. -func AccountSearch(f form.AccountSearch) (result entity.Accounts, err error) { +// Accounts returns a list of accounts. +func Accounts(f form.AccountSearch) (result entity.Accounts, err error) { s := Db().Where(&entity.Account{}) if f.Share { @@ -35,12 +35,3 @@ func AccountSearch(f form.AccountSearch) (result entity.Accounts, err error) { return result, nil } - -// AccountByID finds an account by primary key. -func AccountByID(id uint) (result entity.Account, err error) { - if err := Db().Where("id = ?", id).First(&result).Error; err != nil { - return result, err - } - - return result, nil -} diff --git a/internal/query/account_search_test.go b/internal/search/accounts_test.go similarity index 71% rename from internal/query/account_search_test.go rename to internal/search/accounts_test.go index 7190c71dc..6636e1a2c 100644 --- a/internal/query/account_search_test.go +++ b/internal/search/accounts_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" @@ -19,7 +19,7 @@ func TestAccounts(t *testing.T) { Offset: 0, Order: "", } - r, err := AccountSearch(f) + r, err := Accounts(f) if err != nil { t.Fatal(err) @@ -44,7 +44,7 @@ func TestAccounts(t *testing.T) { Offset: 0, Order: "", } - r, err := AccountSearch(f) + r, err := Accounts(f) if err != nil { t.Fatal(err) @@ -66,7 +66,7 @@ func TestAccounts(t *testing.T) { Offset: 0, Order: "", } - r, err := AccountSearch(f) + r, err := Accounts(f) if err != nil { t.Fatal(err) @@ -81,25 +81,3 @@ func TestAccounts(t *testing.T) { } }) } - -func TestAccountByID(t *testing.T) { - t.Run("existing account", func(t *testing.T) { - r, err := AccountByID(uint(1000001)) - - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "Test Account2", r.AccName) - - }) - t.Run("record not found", func(t *testing.T) { - r, err := AccountByID(uint(123)) - - if err == nil { - t.Fatal() - } - assert.Equal(t, "record not found", err.Error()) - assert.Empty(t, r) - }) -} diff --git a/internal/search/albums.go b/internal/search/albums.go new file mode 100644 index 000000000..800d5ae12 --- /dev/null +++ b/internal/search/albums.go @@ -0,0 +1,92 @@ +package search + +import ( + "strings" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/txt" +) + +// Albums searches albums based on their name. +func Albums(f form.AlbumSearch) (results AlbumResults, err error) { + if err := f.ParseQueryString(); err != nil { + return results, err + } + + // Base query. + s := UnscopedDb().Table("albums"). + Select("albums.*, cp.photo_count, cl.link_count"). + Joins("LEFT JOIN (SELECT album_uid, count(photo_uid) AS photo_count FROM photos_albums WHERE hidden = 0 AND missing = 0 GROUP BY album_uid) AS cp ON cp.album_uid = albums.album_uid"). + Joins("LEFT JOIN (SELECT share_uid, count(share_uid) AS link_count FROM links GROUP BY share_uid) AS cl ON cl.share_uid = albums.album_uid"). + Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL)"). + Where("albums.deleted_at IS NULL") + + // Limit result count. + if f.Count > 0 && f.Count <= MaxResults { + s = s.Limit(f.Count).Offset(f.Offset) + } else { + s = s.Limit(MaxResults).Offset(f.Offset) + } + + // Set sort order. + switch f.Order { + case "slug": + s = s.Order("albums.album_favorite DESC, album_slug ASC") + default: + s = s.Order("albums.album_favorite DESC, albums.album_year DESC, albums.album_month DESC, albums.album_day DESC, albums.album_title, albums.created_at DESC") + } + + if f.ID != "" { + s = s.Where("albums.album_uid IN (?)", strings.Split(f.ID, txt.Or)) + + if result := s.Scan(&results); result.Error != nil { + return results, result.Error + } + + return results, nil + } + + if f.Query != "" { + likeString := "%" + f.Query + "%" + s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString) + } + + if f.Type != "" { + s = s.Where("albums.album_type IN (?)", strings.Split(f.Type, txt.Or)) + } + + if f.Category != "" { + s = s.Where("albums.album_category IN (?)", strings.Split(f.Category, txt.Or)) + } + + if f.Location != "" { + s = s.Where("albums.album_location IN (?)", strings.Split(f.Location, txt.Or)) + } + + if f.Country != "" { + s = s.Where("albums.album_country IN (?)", strings.Split(f.Country, txt.Or)) + } + + if f.Favorite { + s = s.Where("albums.album_favorite = 1") + } + + if (f.Year > 0 && f.Year <= txt.YearMax) || f.Year == entity.UnknownYear { + s = s.Where("albums.album_year = ?", f.Year) + } + + if (f.Month >= txt.MonthMin && f.Month <= txt.MonthMax) || f.Month == entity.UnknownMonth { + s = s.Where("albums.album_month = ?", f.Month) + } + + if (f.Day >= txt.DayMin && f.Month <= txt.DayMax) || f.Day == entity.UnknownDay { + s = s.Where("albums.album_day = ?", f.Day) + } + + if result := s.Scan(&results); result.Error != nil { + return results, result.Error + } + + return results, nil +} diff --git a/internal/search/albums_photos.go b/internal/search/albums_photos.go new file mode 100644 index 000000000..24214b0a6 --- /dev/null +++ b/internal/search/albums_photos.go @@ -0,0 +1,18 @@ +package search + +import ( + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" +) + +// AlbumPhotos returns up to count photos from an album. +func AlbumPhotos(a entity.Album, count int) (results PhotoResults, err error) { + results, _, err = Photos(form.PhotoSearch{ + Album: a.AlbumUID, + Filter: a.AlbumFilter, + Count: count, + Offset: 0, + }) + + return results, err +} diff --git a/internal/search/albums_results.go b/internal/search/albums_results.go new file mode 100644 index 000000000..36b5d2d97 --- /dev/null +++ b/internal/search/albums_results.go @@ -0,0 +1,39 @@ +package search + +import ( + "time" +) + +// Album represents an album search result. +type Album struct { + ID uint `json:"-"` + AlbumUID string `json:"UID"` + ParentUID string `json:"ParentUID"` + Thumb string `json:"Thumb"` + ThumbSrc string `json:"ThumbSrc"` + AlbumSlug string `json:"Slug"` + AlbumType string `json:"Type"` + AlbumTitle string `json:"Title"` + AlbumLocation string `json:"Location"` + AlbumCategory string `json:"Category"` + AlbumCaption string `json:"Caption"` + AlbumDescription string `json:"Description"` + AlbumNotes string `json:"Notes"` + AlbumFilter string `json:"Filter"` + AlbumOrder string `json:"Order"` + AlbumTemplate string `json:"Template"` + AlbumPath string `json:"Path"` + AlbumCountry string `json:"Country"` + AlbumYear int `json:"Year"` + AlbumMonth int `json:"Month"` + AlbumDay int `json:"Day"` + AlbumFavorite bool `json:"Favorite"` + AlbumPrivate bool `json:"Private"` + PhotoCount int `json:"PhotoCount"` + LinkCount int `json:"LinkCount"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt time.Time `json:"DeletedAt,omitempty"` +} + +type AlbumResults []Album diff --git a/internal/search/albums_test.go b/internal/search/albums_test.go new file mode 100644 index 000000000..a92da1374 --- /dev/null +++ b/internal/search/albums_test.go @@ -0,0 +1,155 @@ +package search + +import ( + "testing" + + "github.com/photoprism/photoprism/internal/entity" + form "github.com/photoprism/photoprism/internal/form" + "github.com/stretchr/testify/assert" +) + +func TestAlbumPhotos(t *testing.T) { + t.Run("search with string", func(t *testing.T) { + results, err := AlbumPhotos(entity.AlbumFixtures.Get("april-1990"), 2) + + if err != nil { + t.Fatal(err) + } + + if len(results) < 2 { + t.Errorf("at least 2 results expected: %d", len(results)) + } + }) +} + +func TestAlbums(t *testing.T) { + t.Run("search with string", func(t *testing.T) { + query := form.NewAlbumSearch("chr") + result, err := Albums(query) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Christmas 2030", result[0].AlbumTitle) + }) + + t.Run("search with slug", func(t *testing.T) { + query := form.NewAlbumSearch("slug:holiday count:10") + result, err := Albums(query) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Holiday 2030", result[0].AlbumTitle) + }) + + t.Run("search with country", func(t *testing.T) { + query := form.NewAlbumSearch("country:ca") + result, err := Albums(query) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "April 1990", result[0].AlbumTitle) + }) + + t.Run("favorites true", func(t *testing.T) { + query := form.NewAlbumSearch("favorite:true count:10000") + + result, err := Albums(query) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Holiday 2030", result[0].AlbumTitle) + }) + t.Run("empty query", func(t *testing.T) { + query := form.NewAlbumSearch("order:slug") + + results, err := Albums(query) + + if err != nil { + t.Fatal(err) + } + + if len(results) < 3 { + t.Errorf("at least 3 results expected: %d", len(results)) + } + }) + t.Run("search with invalid query string", func(t *testing.T) { + query := form.NewAlbumSearch("xxx:bla") + result, err := Albums(query) + assert.Error(t, err, "unknown filter") + t.Log(result) + }) + t.Run("search with invalid query string", func(t *testing.T) { + query := form.NewAlbumSearch("xxx:bla") + result, err := Albums(query) + assert.Error(t, err, "unknown filter") + t.Log(result) + }) + t.Run("search for existing ID", func(t *testing.T) { + f := form.AlbumSearch{ + Query: "", + ID: "at9lxuqxpogaaba7", + Slug: "", + Title: "", + Favorite: false, + Count: 0, + Offset: 0, + Order: "", + } + + result, err := Albums(f) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, 1, len(result)) + assert.Equal(t, "christmas-2030", result[0].AlbumSlug) + }) + t.Run("search with multiple filters", func(t *testing.T) { + f := form.AlbumSearch{ + Query: "", + Type: "moment", + Category: "Fun", + Location: "Favorite Park", + Title: "Empty Moment", + Count: 0, + Offset: 0, + Order: "", + } + + result, err := Albums(f) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, 1, len(result)) + assert.Equal(t, "Empty Moment", result[0].AlbumTitle) + }) + t.Run("search for year/month/day", func(t *testing.T) { + f := form.AlbumSearch{ + Year: 2021, + Month: 10, + Day: 3, + Count: 0, + Offset: 0, + Order: "", + } + + result, err := Albums(f) + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, 0, len(result)) + }) +} diff --git a/internal/search/faces.go b/internal/search/faces.go new file mode 100644 index 000000000..4762f15bb --- /dev/null +++ b/internal/search/faces.go @@ -0,0 +1,70 @@ +package search + +import ( + "fmt" + "strings" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/txt" +) + +// Faces searches faces and returns them. +func Faces(f form.FaceSearch) (results FaceResults, err error) { + if err := f.ParseQueryString(); err != nil { + return results, err + } + + // Base query. + s := UnscopedDb().Table(entity.Face{}.TableName()) + + // Limit result count. + if f.Count > 0 && f.Count <= MaxResults { + s = s.Limit(f.Count).Offset(f.Offset) + } else { + s = s.Limit(MaxResults).Offset(f.Offset) + } + + // Set sort order. + switch f.Order { + case "subject": + s = s.Order("subj_uid") + case "added": + s = s.Order(fmt.Sprintf("%s.created_at DESC", entity.Face{}.TableName())) + default: + s = s.Order("samples DESC") + } + + // Find specific IDs? + if f.ID != "" { + s = s.Where(fmt.Sprintf("%s.id IN (?)", entity.Face{}.TableName()), strings.Split(strings.ToUpper(f.ID), txt.Or)) + + if result := s.Scan(&results); result.Error != nil { + return results, result.Error + } else if f.Markers { + // Add markers to results. + for i := range results { + results[i].Marker = entity.FindFaceMarker(results[i].ID) + } + } + + return results, nil + } + + // Find unknown faces only? + if f.Unknown { + s = s.Where("subj_uid = '' OR subj_uid IS NULL") + } + + // Perform query. + if res := s.Scan(&results); res.Error != nil { + return results, res.Error + } else if f.Markers { + // Add markers to results. + for i := range results { + results[i].Marker = entity.FindFaceMarker(results[i].ID) + } + } + + return results, nil +} diff --git a/internal/search/faces_results.go b/internal/search/faces_results.go new file mode 100644 index 000000000..302754a18 --- /dev/null +++ b/internal/search/faces_results.go @@ -0,0 +1,25 @@ +package search + +import ( + "time" + + "github.com/photoprism/photoprism/internal/entity" +) + +// Face represents a face search result. +type Face struct { + ID string `json:"ID"` + FaceSrc string `json:"Src"` + SubjUID string `json:"SubjUID"` + Samples int `json:"Samples"` + SampleRadius float64 `json:"SampleRadius"` + Collisions int `json:"Collisions"` + CollisionRadius float64 `json:"CollisionRadius"` + Marker *entity.Marker `json:"Marker,omitempty"` + MatchedAt *time.Time `json:"MatchedAt" yaml:"MatchedAt,omitempty"` + CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt,omitempty"` + UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"` +} + +// FaceResults represents face search results. +type FaceResults []Face diff --git a/internal/search/faces_test.go b/internal/search/faces_test.go new file mode 100644 index 000000000..f6ca3bd2b --- /dev/null +++ b/internal/search/faces_test.go @@ -0,0 +1,18 @@ +package search + +import ( + "testing" + + "github.com/photoprism/photoprism/internal/form" + + "github.com/stretchr/testify/assert" +) + +func TestFaces(t *testing.T) { + t.Run("Unknown", func(t *testing.T) { + results, err := Faces(form.FaceSearch{Unknown: true, Markers: true}) + assert.NoError(t, err) + t.Logf("Faces: %#v", results) + assert.LessOrEqual(t, 1, len(results)) + }) +} diff --git a/internal/query/labels.go b/internal/search/labels.go similarity index 86% rename from internal/query/labels.go rename to internal/search/labels.go index f123cafbf..6bfeda663 100644 --- a/internal/query/labels.go +++ b/internal/search/labels.go @@ -1,25 +1,20 @@ -package query +package search import ( - "fmt" "strings" - "time" "github.com/gosimple/slug" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/capture" "github.com/photoprism/photoprism/pkg/txt" ) // Labels searches labels based on their name. -func Labels(f form.LabelSearch) (results []LabelResult, err error) { +func Labels(f form.LabelSearch) (results []Label, err error) { if err := f.ParseQueryString(); err != nil { return results, err } - defer log.Debug(capture.Time(time.Now(), fmt.Sprintf("labels: search %s", form.Serialize(f, true)))) - s := UnscopedDb() // s.LogMode(true) @@ -46,7 +41,7 @@ func Labels(f form.LabelSearch) (results []LabelResult, err error) { } if f.ID != "" { - s = s.Where("labels.label_uid IN (?)", strings.Split(f.ID, Or)) + s = s.Where("labels.label_uid IN (?)", strings.Split(f.ID, txt.Or)) if result := s.Scan(&results); result.Error != nil { return results, result.Error diff --git a/internal/query/label_result.go b/internal/search/labels_results.go similarity index 89% rename from internal/query/label_result.go rename to internal/search/labels_results.go index efd8a3e86..4079ee3ff 100644 --- a/internal/query/label_result.go +++ b/internal/search/labels_results.go @@ -1,12 +1,11 @@ -package query +package search import ( "time" ) -// LabelResult contains found labels -type LabelResult struct { - // Label +// Label represents a label search result. +type Label struct { ID uint `json:"ID"` LabelUID string `json:"UID"` Thumb string `json:"Thumb"` diff --git a/internal/query/labels_test.go b/internal/search/labels_test.go similarity index 96% rename from internal/query/labels_test.go rename to internal/search/labels_test.go index e642d0331..ebb7924a1 100644 --- a/internal/query/labels_test.go +++ b/internal/search/labels_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" @@ -22,7 +22,7 @@ func TestLabels(t *testing.T) { assert.LessOrEqual(t, 2, len(result)) for _, r := range result { - assert.IsType(t, LabelResult{}, r) + assert.IsType(t, Label{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.LabelName) assert.NotEmpty(t, r.LabelSlug) @@ -49,7 +49,7 @@ func TestLabels(t *testing.T) { assert.LessOrEqual(t, 1, len(result)) for _, r := range result { - assert.IsType(t, LabelResult{}, r) + assert.IsType(t, Label{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.LabelName) assert.NotEmpty(t, r.LabelSlug) @@ -74,7 +74,7 @@ func TestLabels(t *testing.T) { for _, r := range result { assert.True(t, r.LabelFavorite) - assert.IsType(t, LabelResult{}, r) + assert.IsType(t, Label{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.LabelName) assert.NotEmpty(t, r.LabelSlug) diff --git a/internal/query/like.go b/internal/search/like.go similarity index 79% rename from internal/query/like.go rename to internal/search/like.go index b234e99cb..ed77b8b63 100644 --- a/internal/query/like.go +++ b/internal/search/like.go @@ -1,4 +1,4 @@ -package query +package search import ( "fmt" @@ -10,36 +10,16 @@ import ( "github.com/jinzhu/inflection" ) -// NormalizeSearchQuery replaces search operator with default symbols. -func NormalizeSearchQuery(s string) string { - s = strings.ToLower(txt.Clip(s, txt.ClipQuery)) - s = strings.ReplaceAll(s, OrEn, Or) - s = strings.ReplaceAll(s, AndEn, And) - s = strings.ReplaceAll(s, WithEn, And) - s = strings.ReplaceAll(s, InEn, And) - s = strings.ReplaceAll(s, AtEn, And) - s = strings.ReplaceAll(s, Plus, And) - s = strings.ReplaceAll(s, "%", "*") - return strings.Trim(s, "+&|_-=!@$%^(){}\\<>,.;: ") -} - -// IsTooShort tests if a search query is too short. -func IsTooShort(q string) bool { - q = strings.Trim(q, "- '") - - return q != "" && len(q) < 3 && txt.IsLatin(q) -} - // LikeAny returns a single where condition matching the search words. func LikeAny(col, s string, keywords, exact bool) (wheres []string) { if s == "" { return wheres } - s = strings.ReplaceAll(s, Or, " ") - s = strings.ReplaceAll(s, OrEn, " ") - s = strings.ReplaceAll(s, AndEn, And) - s = strings.ReplaceAll(s, Plus, And) + s = strings.ReplaceAll(s, txt.Or, " ") + s = strings.ReplaceAll(s, txt.OrEn, " ") + s = strings.ReplaceAll(s, txt.AndEn, txt.And) + s = strings.ReplaceAll(s, txt.Plus, txt.And) var wildcardThreshold int @@ -51,7 +31,7 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) { wildcardThreshold = 2 } - for _, k := range strings.Split(s, And) { + for _, k := range strings.Split(s, txt.And) { var orWheres []string var words []string @@ -151,19 +131,19 @@ func LikeAllNames(cols Cols, s string) (wheres []string) { return wheres } - for _, k := range strings.Split(s, And) { + for _, k := range strings.Split(s, txt.And) { var orWheres []string - for _, w := range strings.Split(k, Or) { + for _, w := range strings.Split(k, txt.Or) { w = strings.TrimSpace(w) - if w == Empty || len(w) < 2 && txt.IsLatin(w) { + if w == txt.Empty || len(w) < 2 && txt.IsLatin(w) { continue } for _, c := range cols { if len(w) > 4 { - if strings.Contains(w, Space) { + if strings.Contains(w, txt.Space) { orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%s%%'", c, w)) } else { orWheres = append(orWheres, fmt.Sprintf("%s LIKE '%%%s%%'", c, w)) diff --git a/internal/query/like_test.go b/internal/search/like_test.go similarity index 91% rename from internal/query/like_test.go rename to internal/search/like_test.go index fbfcccb5a..5ee396421 100644 --- a/internal/query/like_test.go +++ b/internal/search/like_test.go @@ -1,33 +1,13 @@ -package query +package search import ( "testing" + "github.com/photoprism/photoprism/pkg/txt" + "github.com/stretchr/testify/assert" ) -func TestNormalizeSearchQuery(t *testing.T) { - t.Run("Replace", func(t *testing.T) { - q := NormalizeSearchQuery("table spoon & usa | img% json OR BILL!") - assert.Equal(t, "table spoon & usa | img* json|bill", q) - }) -} - -func TestIsTooShort(t *testing.T) { - t.Run("Empty", func(t *testing.T) { - assert.False(t, IsTooShort("")) - }) - t.Run("IsTooShort", func(t *testing.T) { - assert.True(t, IsTooShort("aa")) - }) - t.Run("Chinese", func(t *testing.T) { - assert.False(t, IsTooShort("李")) - }) - t.Run("OK", func(t *testing.T) { - assert.False(t, IsTooShort("foo")) - }) -} - func TestLikeAny(t *testing.T) { t.Run("and_or_search", func(t *testing.T) { if w := LikeAny("k.keyword", "table spoon & usa | img json", true, false); len(w) != 2 { @@ -222,7 +202,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("Plus", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, NormalizeSearchQuery("Paul + Paula")); len(w) == 2 { + if w := LikeAllNames(Cols{"name"}, txt.NormalizeQuery("Paul + Paula")); len(w) == 2 { assert.Equal(t, "name LIKE 'paul' OR name LIKE 'paul %'", w[0]) assert.Equal(t, "name LIKE '%paula%'", w[1]) } else { @@ -230,7 +210,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("Ane", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, NormalizeSearchQuery("Paul and Paula")); len(w) == 2 { + if w := LikeAllNames(Cols{"name"}, txt.NormalizeQuery("Paul and Paula")); len(w) == 2 { assert.Equal(t, "name LIKE 'paul' OR name LIKE 'paul %'", w[0]) assert.Equal(t, "name LIKE '%paula%'", w[1]) } else { @@ -238,7 +218,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("Or", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, NormalizeSearchQuery("Paul or Paula")); len(w) == 1 { + if w := LikeAllNames(Cols{"name"}, txt.NormalizeQuery("Paul or Paula")); len(w) == 1 { assert.Equal(t, "name LIKE 'paul' OR name LIKE 'paul %' OR name LIKE '%paula%'", w[0]) } else { t.Fatalf("one where conditions expected: %#v", w) @@ -278,7 +258,7 @@ func TestAnySlug(t *testing.T) { }) t.Run("comma separated", func(t *testing.T) { - where := AnySlug("custom_slug", "botanical-garden|landscape|bay", Or) + where := AnySlug("custom_slug", "botanical-garden|landscape|bay", txt.Or) assert.Equal(t, "custom_slug = 'botanical-garden' OR custom_slug = 'landscape' OR custom_slug = 'bay'", where) }) diff --git a/internal/query/photo_search.go b/internal/search/photos.go similarity index 89% rename from internal/query/photo_search.go rename to internal/search/photos.go index aa5b56efd..8e3aa1318 100644 --- a/internal/query/photo_search.go +++ b/internal/search/photos.go @@ -1,4 +1,4 @@ -package query +package search import ( "fmt" @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) -// PhotoSearch searches for photos based on a Form and returns PhotoResults ([]PhotoResult). -func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error) { +// Photos searches for photos based on a Form and returns PhotoResults ([]Photo). +func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) { start := time.Now() if err := f.ParseQueryString(); err != nil { @@ -89,7 +89,7 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error // Shortcut for known photo ids. if f.ID != "" { - s = s.Where("photos.photo_uid IN (?)", strings.Split(f.ID, Or)) + s = s.Where("photos.photo_uid IN (?)", strings.Split(f.ID, txt.Or)) s = s.Order("files.file_primary DESC") if result := s.Scan(&results); result.Error != nil { @@ -111,7 +111,7 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error var labelIds []uint if f.Label != "" { - if err := Db().Where(AnySlug("label_slug", f.Label, Or)).Or(AnySlug("custom_slug", f.Label, Or)).Find(&labels).Error; len(labels) == 0 || err != nil { + if err := Db().Where(AnySlug("label_slug", f.Label, txt.Or)).Or(AnySlug("custom_slug", f.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || err != nil { log.Errorf("search: labels %s not found", txt.Quote(f.Label)) return results, 0, fmt.Errorf("%s not found", txt.Quote(f.Label)) } else { @@ -133,12 +133,12 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error } // Clip to reasonable size and normalize operators. - f.Query = NormalizeSearchQuery(f.Query) + f.Query = txt.NormalizeQuery(f.Query) // Modify query if it contains subject names. if f.Query != "" && f.Subject == "" { - if subj, names, remaining := SearchSubjUIDs(f.Query); len(subj) > 0 { - f.Subject = strings.Join(subj, And) + if subj, names, remaining := SubjectUIDs(f.Query); len(subj) > 0 { + f.Subject = strings.Join(subj, txt.And) log.Debugf("people: searching for %s", txt.Quote(txt.JoinNames(names))) f.Query = remaining } @@ -222,11 +222,19 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error } } + // Filter for one or more faces? + if f.Face != "" { + for _, f := range strings.Split(strings.ToUpper(f.Face), txt.And) { + s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE face_id IN (?))", + entity.Marker{}.TableName()), strings.Split(f, txt.Or)) + } + } + // Filter for one or more subjects? if f.Subject != "" { - for _, subj := range strings.Split(strings.ToLower(f.Subject), And) { + for _, subj := range strings.Split(strings.ToLower(f.Subject), txt.And) { s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))", - entity.Marker{}.TableName()), strings.Split(subj, Or)) + entity.Marker{}.TableName()), strings.Split(subj, txt.Or)) } } else if f.Subjects != "" { for _, where := range LikeAnyWord("s.subj_name", f.Subjects) { @@ -293,7 +301,7 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error } if f.Color != "" { - s = s.Where("files.file_main_color IN (?)", strings.Split(strings.ToLower(f.Color), Or)) + s = s.Where("files.file_main_color IN (?)", strings.Split(strings.ToLower(f.Color), txt.Or)) } if f.Favorite { @@ -315,21 +323,21 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error } if f.Country != "" { - s = s.Where("photos.photo_country IN (?)", strings.Split(strings.ToLower(f.Country), Or)) + s = s.Where("photos.photo_country IN (?)", strings.Split(strings.ToLower(f.Country), txt.Or)) } if f.State != "" { - s = s.Where("places.place_state IN (?)", strings.Split(f.State, Or)) + s = s.Where("places.place_state IN (?)", strings.Split(f.State, txt.Or)) } if f.Category != "" { s = s.Joins("JOIN cells ON photos.cell_id = cells.id"). - Where("cells.cell_category IN (?)", strings.Split(strings.ToLower(f.Category), Or)) + Where("cells.cell_category IN (?)", strings.Split(strings.ToLower(f.Category), txt.Or)) } // Filter by media type. if f.Type != "" { - s = s.Where("photos.photo_type IN (?)", strings.Split(strings.ToLower(f.Type), Or)) + s = s.Where("photos.photo_type IN (?)", strings.Split(strings.ToLower(f.Type), txt.Or)) } if f.Video { @@ -347,41 +355,41 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error if strings.HasSuffix(p, "/") { s = s.Where("photos.photo_path = ?", p[:len(p)-1]) - } else if strings.Contains(p, Or) { - s = s.Where("photos.photo_path IN (?)", strings.Split(p, Or)) + } else if strings.Contains(p, txt.Or) { + s = s.Where("photos.photo_path IN (?)", strings.Split(p, txt.Or)) } else { s = s.Where("photos.photo_path LIKE ?", strings.ReplaceAll(p, "*", "%")) } } - if strings.Contains(f.Name, Or) { - s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, Or)) + if strings.Contains(f.Name, txt.Or) { + s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, txt.Or)) } else if f.Name != "" { s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%")) } - if strings.Contains(f.Filename, Or) { - s = s.Where("files.file_name IN (?)", strings.Split(f.Filename, Or)) + if strings.Contains(f.Filename, txt.Or) { + s = s.Where("files.file_name IN (?)", strings.Split(f.Filename, txt.Or)) } else if f.Filename != "" { s = s.Where("files.file_name LIKE ?", strings.ReplaceAll(f.Filename, "*", "%")) } - if strings.Contains(f.Original, Or) { - s = s.Where("photos.original_name IN (?)", strings.Split(f.Original, Or)) + if strings.Contains(f.Original, txt.Or) { + s = s.Where("photos.original_name IN (?)", strings.Split(f.Original, txt.Or)) } else if f.Original != "" { s = s.Where("photos.original_name LIKE ?", strings.ReplaceAll(f.Original, "*", "%")) } - if strings.Contains(f.Title, Or) { - s = s.Where("photos.photo_title IN (?)", strings.Split(strings.ToLower(f.Title), Or)) + if strings.Contains(f.Title, txt.Or) { + s = s.Where("photos.photo_title IN (?)", strings.Split(strings.ToLower(f.Title), txt.Or)) } else if f.Title != "" { s = s.Where("photos.photo_title LIKE ?", strings.ReplaceAll(strings.ToLower(f.Title), "*", "%")) } - if strings.Contains(f.Hash, Or) { - s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), Or)) + if strings.Contains(f.Hash, txt.Or) { + s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), txt.Or)) } else if f.Hash != "" { - s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), Or)) + s = s.Where("files.file_hash IN (?)", strings.Split(strings.ToLower(f.Hash), txt.Or)) } if f.Portrait { @@ -416,13 +424,13 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error // Filter by approx distance to coordinates: if f.Lat != 0 { - latMin := f.Lat - SearchRadius*float32(f.Dist) - latMax := f.Lat + SearchRadius*float32(f.Dist) + latMin := f.Lat - Radius*float32(f.Dist) + latMax := f.Lat + Radius*float32(f.Dist) s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax) } if f.Lng != 0 { - lngMin := f.Lng - SearchRadius*float32(f.Dist) - lngMax := f.Lng + SearchRadius*float32(f.Dist) + lngMin := f.Lng - Radius*float32(f.Dist) + lngMax := f.Lng + Radius*float32(f.Dist) s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax) } diff --git a/internal/query/geo.go b/internal/search/photos_geo.go similarity index 87% rename from internal/query/geo.go rename to internal/search/photos_geo.go index c2afa1a49..5fe7cdc1e 100644 --- a/internal/query/geo.go +++ b/internal/search/photos_geo.go @@ -1,4 +1,4 @@ -package query +package search import ( "fmt" @@ -10,22 +10,19 @@ import ( "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/capture" "github.com/photoprism/photoprism/pkg/pluscode" "github.com/photoprism/photoprism/pkg/s2" "github.com/photoprism/photoprism/pkg/txt" ) -// Geo searches for photos based on Form values and returns GeoResults ([]GeoResult). -func Geo(f form.GeoSearch) (results GeoResults, err error) { +// PhotosGeo searches for photos based on Form values and returns GeoResults ([]GeoResult). +func PhotosGeo(f form.GeoSearch) (results GeoResults, err error) { start := time.Now() if err := f.ParseQueryString(); err != nil { return results, err } - defer log.Debug(capture.Time(time.Now(), fmt.Sprintf("geo: search %s", form.Serialize(f, true)))) - s := UnscopedDb() // s.LogMode(true) @@ -40,12 +37,12 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { Where("photos.photo_lat <> 0") // Clip to reasonable size and normalize operators. - f.Query = NormalizeSearchQuery(f.Query) + f.Query = txt.NormalizeQuery(f.Query) // Modify query if it contains subject names. if f.Query != "" && f.Subject == "" { - if subj, names, remaining := SearchSubjUIDs(f.Query); len(subj) > 0 { - f.Subject = strings.Join(subj, And) + if subj, names, remaining := SubjectUIDs(f.Query); len(subj) > 0 { + f.Subject = strings.Join(subj, txt.And) log.Debugf("search: subject %s", txt.Quote(strings.Join(names, ", "))) f.Query = remaining } @@ -115,11 +112,19 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { } } + // Filter for one or more faces? + if f.Face != "" { + for _, f := range strings.Split(strings.ToUpper(f.Face), txt.And) { + s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE face_id IN (?))", + entity.Marker{}.TableName()), strings.Split(f, txt.Or)) + } + } + // Filter for one or more subjects? if f.Subject != "" { - for _, subj := range strings.Split(strings.ToLower(f.Subject), And) { + for _, subj := range strings.Split(strings.ToLower(f.Subject), txt.And) { s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))", - entity.Marker{}.TableName()), strings.Split(subj, Or)) + entity.Marker{}.TableName()), strings.Split(subj, txt.Or)) } } else if f.Subjects != "" { for _, where := range LikeAnyWord("s.subj_name", f.Subjects) { @@ -173,7 +178,7 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { } if f.Color != "" { - s = s.Where("files.file_main_color IN (?)", strings.Split(strings.ToLower(f.Color), Or)) + s = s.Where("files.file_main_color IN (?)", strings.Split(strings.ToLower(f.Color), txt.Or)) } if f.Favorite { @@ -181,12 +186,12 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { } if f.Country != "" { - s = s.Where("photos.photo_country IN (?)", strings.Split(strings.ToLower(f.Country), Or)) + s = s.Where("photos.photo_country IN (?)", strings.Split(strings.ToLower(f.Country), txt.Or)) } // Filter by media type. if f.Type != "" { - s = s.Where("photos.photo_type IN (?)", strings.Split(strings.ToLower(f.Type), Or)) + s = s.Where("photos.photo_type IN (?)", strings.Split(strings.ToLower(f.Type), txt.Or)) } if f.Video { @@ -204,15 +209,15 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { if strings.HasSuffix(p, "/") { s = s.Where("photos.photo_path = ?", p[:len(p)-1]) - } else if strings.Contains(p, Or) { - s = s.Where("photos.photo_path IN (?)", strings.Split(p, Or)) + } else if strings.Contains(p, txt.Or) { + s = s.Where("photos.photo_path IN (?)", strings.Split(p, txt.Or)) } else { s = s.Where("photos.photo_path LIKE ?", strings.ReplaceAll(p, "*", "%")) } } - if strings.Contains(f.Name, Or) { - s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, Or)) + if strings.Contains(f.Name, txt.Or) { + s = s.Where("photos.photo_name IN (?)", strings.Split(f.Name, txt.Or)) } else if f.Name != "" { s = s.Where("photos.photo_name LIKE ?", strings.ReplaceAll(fs.StripKnownExt(f.Name), "*", "%")) } @@ -250,13 +255,13 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) { } else { // Filter by approx distance to coordinates: if f.Lat != 0 { - latMin := f.Lat - SearchRadius*float32(f.Dist) - latMax := f.Lat + SearchRadius*float32(f.Dist) + latMin := f.Lat - Radius*float32(f.Dist) + latMax := f.Lat + Radius*float32(f.Dist) s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax) } if f.Lng != 0 { - lngMin := f.Lng - SearchRadius*float32(f.Dist) - lngMax := f.Lng + SearchRadius*float32(f.Dist) + lngMin := f.Lng - Radius*float32(f.Dist) + lngMax := f.Lng + Radius*float32(f.Dist) s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax) } } diff --git a/internal/query/geo_result.go b/internal/search/photos_geo_result.go similarity index 91% rename from internal/query/geo_result.go rename to internal/search/photos_geo_result.go index 655ddcb9d..7b8893194 100644 --- a/internal/query/geo_result.go +++ b/internal/search/photos_geo_result.go @@ -1,10 +1,10 @@ -package query +package search import ( "time" ) -// GeoResult represents a photo for displaying it on a map. +// GeoResult represents a photo geo json search result. type GeoResult struct { ID string `json:"-"` PhotoUID string `json:"UID"` diff --git a/internal/query/geo_result_test.go b/internal/search/photos_geo_result_test.go similarity index 98% rename from internal/query/geo_result_test.go rename to internal/search/photos_geo_result_test.go index 630f83df3..6e5b7bffa 100644 --- a/internal/query/geo_result_test.go +++ b/internal/search/photos_geo_result_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" diff --git a/internal/query/geo_test.go b/internal/search/photos_geo_test.go similarity index 88% rename from internal/query/geo_test.go rename to internal/search/photos_geo_test.go index 0f8759063..201ab35ee 100644 --- a/internal/query/geo_test.go +++ b/internal/search/photos_geo_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" @@ -14,7 +14,7 @@ func TestGeo(t *testing.T) { t.Run("form.keywords", func(t *testing.T) { query := form.NewGeoSearch("keywords:bridge") - if result, err := Geo(query); err != nil { + if result, err := PhotosGeo(query); err != nil { t.Fatal(err) } else { assert.GreaterOrEqual(t, len(result), 1) @@ -23,7 +23,7 @@ func TestGeo(t *testing.T) { t.Run("form.subjects", func(t *testing.T) { query := form.NewGeoSearch("subjects:John") - if result, err := Geo(query); err != nil { + if result, err := PhotosGeo(query); err != nil { t.Fatal(err) } else { assert.GreaterOrEqual(t, len(result), 0) @@ -32,7 +32,7 @@ func TestGeo(t *testing.T) { t.Run("find_all", func(t *testing.T) { query := form.NewGeoSearch("") - if result, err := Geo(query); err != nil { + if result, err := PhotosGeo(query); err != nil { t.Fatal(err) } else { assert.LessOrEqual(t, 4, len(result)) @@ -41,7 +41,7 @@ func TestGeo(t *testing.T) { t.Run("search for bridge", func(t *testing.T) { query := form.NewGeoSearch("Query:bridge Before:3006-01-02") - result, err := Geo(query) + result, err := PhotosGeo(query) t.Logf("RESULT: %+v", result) if err != nil { @@ -54,7 +54,7 @@ func TestGeo(t *testing.T) { t.Run("search for date range", func(t *testing.T) { query := form.NewGeoSearch("After:2014-12-02 Before:3006-01-02") - result, err := Geo(query) + result, err := PhotosGeo(query) // t.Logf("RESULT: %+v", result) @@ -80,7 +80,7 @@ func TestGeo(t *testing.T) { Review: true, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -108,7 +108,7 @@ func TestGeo(t *testing.T) { Review: false, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -131,7 +131,7 @@ func TestGeo(t *testing.T) { Review: false, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -154,7 +154,7 @@ func TestGeo(t *testing.T) { Review: false, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -166,7 +166,7 @@ func TestGeo(t *testing.T) { Query: "flower", } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) } @@ -191,7 +191,7 @@ func TestGeo(t *testing.T) { Private: true, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) } @@ -209,7 +209,7 @@ func TestGeo(t *testing.T) { Public: true, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) } @@ -225,7 +225,7 @@ func TestGeo(t *testing.T) { Archived: true, } - result, err := Geo(f) + result, err := PhotosGeo(f) if err != nil { t.Fatal(err) } @@ -236,7 +236,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Query = "faces:true" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -248,7 +248,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Faces = "Yes" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -260,7 +260,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Faces = "No" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -272,7 +272,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Faces = "2" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -285,7 +285,7 @@ func TestGeo(t *testing.T) { f.Day = 18 f.Month = 4 - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -297,7 +297,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Query = "Actress" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -309,7 +309,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Albums = "2030" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -321,7 +321,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Path = "1990/04" + "|" + "2015/11" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) @@ -333,7 +333,7 @@ func TestGeo(t *testing.T) { var f form.GeoSearch f.Name = "20151101_000000_51C501B5" + "|" + "Video" - photos, err := Geo(f) + photos, err := PhotosGeo(f) if err != nil { t.Fatal(err) diff --git a/internal/query/photo_results.go b/internal/search/photos_results.go similarity index 95% rename from internal/query/photo_results.go rename to internal/search/photos_results.go index 8e018c77e..9ce69e860 100644 --- a/internal/query/photo_results.go +++ b/internal/search/photos_results.go @@ -1,4 +1,4 @@ -package query +package search import ( "fmt" @@ -10,8 +10,8 @@ import ( "github.com/ulule/deepcopier" ) -// PhotoResult contains found photos and their main file plus other meta data. -type PhotoResult struct { +// Photo represents a photo search result. +type Photo struct { ID uint `json:"-"` CompositeID string `json:"ID"` UUID string `json:"DocumentID,omitempty"` @@ -98,7 +98,7 @@ type PhotoResult struct { Files []entity.File `json:"Files"` } -type PhotoResults []PhotoResult +type PhotoResults []Photo // UIDs returns a slice of photo UIDs. func (m PhotoResults) UIDs() []string { @@ -113,7 +113,7 @@ func (m PhotoResults) UIDs() []string { func (m PhotoResults) Merged() (PhotoResults, int, error) { count := len(m) - merged := make([]PhotoResult, 0, count) + merged := make([]Photo, 0, count) var lastId uint var i int @@ -146,7 +146,7 @@ func (m PhotoResults) Merged() (PhotoResults, int, error) { } // ShareBase returns a meaningful file name for sharing. -func (m *PhotoResult) ShareBase(seq int) string { +func (m *Photo) ShareBase(seq int) string { var name string if m.PhotoTitle != "" { diff --git a/internal/query/photo_results_test.go b/internal/search/photos_results_test.go similarity index 98% rename from internal/query/photo_results_test.go rename to internal/search/photos_results_test.go index 5764a5a8d..95793e84e 100644 --- a/internal/query/photo_results_test.go +++ b/internal/search/photos_results_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" @@ -8,7 +8,7 @@ import ( ) func TestPhotosResults_Merged(t *testing.T) { - result1 := PhotoResult{ + result1 := Photo{ ID: 111111, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -67,7 +67,7 @@ func TestPhotosResults_Merged(t *testing.T) { Files: nil, } - result2 := PhotoResult{ + result2 := Photo{ ID: 22222, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -137,7 +137,7 @@ func TestPhotosResults_Merged(t *testing.T) { t.Log(merged) } func TestPhotosResults_UIDs(t *testing.T) { - result1 := PhotoResult{ + result1 := Photo{ ID: 111111, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -196,7 +196,7 @@ func TestPhotosResults_UIDs(t *testing.T) { Files: nil, } - result2 := PhotoResult{ + result2 := Photo{ ID: 22222, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -263,7 +263,7 @@ func TestPhotosResults_UIDs(t *testing.T) { func TestPhotosResult_ShareFileName(t *testing.T) { t.Run("with photo title", func(t *testing.T) { - result1 := PhotoResult{ + result1 := Photo{ ID: 111111, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -326,7 +326,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) { assert.Contains(t, r, "20131111-090718-Phototitle123") }) t.Run("without photo title", func(t *testing.T) { - result1 := PhotoResult{ + result1 := Photo{ ID: 111111, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, @@ -390,7 +390,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) { }) t.Run("seq > 0", func(t *testing.T) { - result1 := PhotoResult{ + result1 := Photo{ ID: 111111, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, diff --git a/internal/query/photo_search_test.go b/internal/search/photos_test.go similarity index 86% rename from internal/query/photo_search_test.go rename to internal/search/photos_test.go index 9877ec7dc..6607c2de5 100644 --- a/internal/query/photo_search_test.go +++ b/internal/search/photos_test.go @@ -1,4 +1,4 @@ -package query +package search import ( "testing" @@ -10,7 +10,7 @@ import ( "github.com/photoprism/photoprism/internal/form" ) -func TestPhotoSearch(t *testing.T) { +func TestPhotos(t *testing.T) { t.Run("Chinese", func(t *testing.T) { var frm form.PhotoSearch @@ -18,7 +18,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 10 frm.Offset = 0 - _, _, err := PhotoSearch(frm) + _, _, err := Photos(frm) assert.NoError(t, err) }) @@ -29,7 +29,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 10 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -38,7 +38,7 @@ func TestPhotoSearch(t *testing.T) { assert.LessOrEqual(t, 3, len(photos)) for _, r := range photos { - assert.IsType(t, PhotoResult{}, r) + assert.IsType(t, Photo{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.CameraID) assert.NotEmpty(t, r.LensID) @@ -57,7 +57,7 @@ func TestPhotoSearch(t *testing.T) { frm.ID = "pt9jtdre2lvl0yh7" frm.Merged = true - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -73,7 +73,7 @@ func TestPhotoSearch(t *testing.T) { frm.ID = "pt9jtdre2lvl0yh7" frm.Merged = false - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -87,7 +87,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 10 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) assert.Equal(t, "dog not found", err.Error()) assert.Empty(t, photos) @@ -99,7 +99,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 10 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) } @@ -113,7 +113,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 10 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) assert.Error(t, err) assert.Empty(t, photos) @@ -130,7 +130,7 @@ func TestPhotoSearch(t *testing.T) { frm.Offset = 0 frm.Geo = true - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -147,7 +147,7 @@ func TestPhotoSearch(t *testing.T) { frm.Geo = true frm.Error = false - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -162,7 +162,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 5000 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -177,7 +177,7 @@ func TestPhotoSearch(t *testing.T) { frm.Count = 5000 frm.Offset = 0 - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -193,7 +193,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Archived = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -209,7 +209,7 @@ func TestPhotoSearch(t *testing.T) { f.Private = true f.Error = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -224,7 +224,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Public = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -239,7 +239,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Review = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -255,7 +255,7 @@ func TestPhotoSearch(t *testing.T) { f.Quality = 3 f.Private = false - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -270,7 +270,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Error = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -285,7 +285,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Camera = 1000003 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -300,7 +300,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Color = "blue" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -314,7 +314,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -328,7 +328,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -343,7 +343,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -359,7 +359,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -368,13 +368,28 @@ func TestPhotoSearch(t *testing.T) { //t.Logf("results: %+v", photos) assert.GreaterOrEqual(t, len(photos), 4) }) + t.Run("form.face", func(t *testing.T) { + var f form.PhotoSearch + f.Query = "face:PN6QO5INYTUSAATOFL43LL2ABAV5ACZK" + f.Count = 10 + f.Offset = 0 + + photos, _, err := Photos(f) + + if err != nil { + t.Fatal(err) + } + + //t.Logf("results: %+v", photos) + assert.Equal(t, 1, len(photos)) + }) t.Run("form.subject", func(t *testing.T) { var f form.PhotoSearch f.Query = "subject:jqu0xs11qekk9jx8" f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -389,7 +404,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -404,7 +419,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -419,7 +434,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 3 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -435,7 +450,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -452,7 +467,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Archived = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -466,7 +481,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 3 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -482,7 +497,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Error = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -497,7 +512,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -511,7 +526,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -526,7 +541,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -540,7 +555,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -555,7 +570,7 @@ func TestPhotoSearch(t *testing.T) { f.Offset = 0 f.Merged = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -569,7 +584,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 5000 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -586,7 +601,7 @@ func TestPhotoSearch(t *testing.T) { f.Year = 2790 f.Album = "at9lxuqxpogaaba8" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -598,7 +613,7 @@ func TestPhotoSearch(t *testing.T) { f.Query = "" f.Albums = "Berlin" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -609,7 +624,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.State = "KwaZulu-Natal" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -621,7 +636,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Category = "botanical garden" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -634,7 +649,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Label = "botanical-garden|nature|landscape|park" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -647,7 +662,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Primary = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -660,7 +675,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Query = "landscape" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -681,7 +696,7 @@ func TestPhotoSearch(t *testing.T) { f.Path = "/xxx/xxx/" f.Order = entity.SortOrderName - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -705,7 +720,7 @@ func TestPhotoSearch(t *testing.T) { f.Filter = "" f.Order = entity.SortOrderAdded - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -721,7 +736,7 @@ func TestPhotoSearch(t *testing.T) { frm.Offset = 0 frm.Order = entity.SortOrderEdited - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -730,7 +745,7 @@ func TestPhotoSearch(t *testing.T) { assert.GreaterOrEqual(t, len(photos), 1) for _, r := range photos { - assert.IsType(t, PhotoResult{}, r) + assert.IsType(t, Photo{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.CameraID) assert.NotEmpty(t, r.LensID) @@ -750,7 +765,7 @@ func TestPhotoSearch(t *testing.T) { frm.Stackable = false frm.Unstacked = true - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -759,7 +774,7 @@ func TestPhotoSearch(t *testing.T) { assert.GreaterOrEqual(t, len(photos), 1) for _, r := range photos { - assert.IsType(t, PhotoResult{}, r) + assert.IsType(t, Photo{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.CameraID) assert.NotEmpty(t, r.LensID) @@ -780,7 +795,7 @@ func TestPhotoSearch(t *testing.T) { frm.Title = "xxx|photowitheditedatdate" frm.Hash = "xxx|pcad9a68fa6acc5c5ba965adf6ec465ca42fd887" - photos, _, err := PhotoSearch(frm) + photos, _, err := Photos(frm) if err != nil { t.Fatal(err) @@ -789,7 +804,7 @@ func TestPhotoSearch(t *testing.T) { assert.GreaterOrEqual(t, len(photos), 1) for _, r := range photos { - assert.IsType(t, PhotoResult{}, r) + assert.IsType(t, Photo{}, r) assert.NotEmpty(t, r.ID) assert.NotEmpty(t, r.CameraID) assert.NotEmpty(t, r.LensID) @@ -805,7 +820,7 @@ func TestPhotoSearch(t *testing.T) { f.Count = 10 f.Offset = 0 - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -817,7 +832,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Faces = "Yes" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -829,7 +844,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Faces = "No" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -841,7 +856,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Faces = "2" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -853,7 +868,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Filename = "1990/04/Quality1FavoriteTrue.jpg" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -865,7 +880,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Original = "my-videos/IMG_88888" + "|" + "Vacation/exampleFileNameOriginal" - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) @@ -877,7 +892,7 @@ func TestPhotoSearch(t *testing.T) { var f form.PhotoSearch f.Stack = true - photos, _, err := PhotoSearch(f) + photos, _, err := Photos(f) if err != nil { t.Fatal(err) diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 000000000..3e12d2bcf --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,69 @@ +/* + +Package search performs common index search queries. + +Copyright (c) 2018 - 2021 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required + to describe our software, run your own server, for educational purposes, but not for + offering commercial goods, products, or services without prior written permission. + In other words, please ask. + +Feel free to send an e-mail to hello@photoprism.org if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: +https://docs.photoprism.org/developer-guide/ + +*/ +package search + +import ( + "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/event" +) + +var log = event.Log + +// MaxResults is max result limit for queries. +const MaxResults = 10000 + +// Radius is about 1 km. +const Radius = 0.009 + +// Cols represents a list of database columns. +type Cols []string + +// Query searches given an originals path and a db instance. +type Query struct { + db *gorm.DB +} + +// Count represents the total number of search results. +type Count struct { + Total int +} + +// Db returns a database connection instance. +func Db() *gorm.DB { + return entity.Db() +} + +// UnscopedDb returns an unscoped database connection instance. +func UnscopedDb() *gorm.DB { + return entity.Db().Unscoped() +} diff --git a/internal/search/search_test.go b/internal/search/search_test.go new file mode 100644 index 000000000..d07765436 --- /dev/null +++ b/internal/search/search_test.go @@ -0,0 +1,25 @@ +package search + +import ( + "os" + "testing" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/sirupsen/logrus" +) + +func TestMain(m *testing.M) { + log = logrus.StandardLogger() + log.SetLevel(logrus.DebugLevel) + + if err := os.Remove(".test.db"); err == nil { + log.Debugln("removed .test.db") + } + + db := entity.InitTestDb(os.Getenv("PHOTOPRISM_TEST_DRIVER"), os.Getenv("PHOTOPRISM_TEST_DSN")) + defer db.Close() + + code := m.Run() + + os.Exit(code) +} diff --git a/internal/query/subject_search.go b/internal/search/subjects.go similarity index 54% rename from internal/query/subject_search.go rename to internal/search/subjects.go index a3197bb07..9d304d89f 100644 --- a/internal/query/subject_search.go +++ b/internal/search/subjects.go @@ -1,44 +1,22 @@ -package query +package search import ( "fmt" "strings" - "time" + + "github.com/photoprism/photoprism/pkg/txt" "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/capture" ) -// SubjectResult represents a subject search result. -type SubjectResult struct { - SubjUID string `json:"UID"` - MarkerUID string `json:"MarkerUID"` - MarkerSrc string `json:"MarkerSrc,omitempty"` - SubjType string `json:"Type"` - SubjSlug string `json:"Slug"` - SubjName string `json:"Name"` - SubjAlias string `json:"Alias"` - SubjFavorite bool `json:"Favorite"` - SubjPrivate bool `json:"Private"` - SubjExcluded bool `json:"Excluded"` - FileCount int `json:"FileCount"` - FileHash string `json:"FileHash"` - CropArea string `json:"CropArea"` -} - -// SubjectResults represents subject search results. -type SubjectResults []SubjectResult - -// SubjectSearch searches subjects and returns them. -func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) { +// Subjects searches subjects and returns them. +func Subjects(f form.SubjectSearch) (results SubjectResults, err error) { if err := f.ParseQueryString(); err != nil { return results, err } - defer log.Debug(capture.Time(time.Now(), fmt.Sprintf("subjects: search %s", form.Serialize(f, true)))) - // Base query. s := UnscopedDb().Table(entity.Subject{}.TableName()). Select(fmt.Sprintf("%s.*, m.file_hash, m.crop_area", entity.Subject{}.TableName())) @@ -68,7 +46,7 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) { } if f.ID != "" { - s = s.Where(fmt.Sprintf("%s.subj_uid IN (?)", entity.Subject{}.TableName()), strings.Split(f.ID, Or)) + s = s.Where(fmt.Sprintf("%s.subj_uid IN (?)", entity.Subject{}.TableName()), strings.Split(f.ID, txt.Or)) if result := s.Scan(&results); result.Error != nil { return results, result.Error @@ -88,7 +66,7 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) { } if f.Type != "" { - s = s.Where("subj_type IN (?)", strings.Split(f.Type, Or)) + s = s.Where("subj_type IN (?)", strings.Split(f.Type, txt.Or)) } if f.Favorite { @@ -112,3 +90,60 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) { return results, nil } + +// SubjectUIDs finds subject UIDs matching the search string, and removes names from the remaining query. +func SubjectUIDs(s string) (result []string, names []string, remaining string) { + if s == "" { + return result, names, s + } + + type Matches struct { + SubjUID string + SubjName string + SubjAlias string + } + + var matches []Matches + + wheres := LikeAllNames(Cols{"subj_name", "subj_alias"}, s) + + if len(wheres) == 0 { + return result, names, s + } + + remaining = s + + for _, where := range wheres { + var subj []string + + stmt := Db().Model(entity.Subject{}) + stmt = stmt.Where("?", gorm.Expr(where)) + + if err := stmt.Scan(&matches).Error; err != nil { + log.Errorf("search: %s while finding subjects", err) + } else if len(matches) == 0 { + continue + } + + for _, m := range matches { + subj = append(subj, m.SubjUID) + names = append(names, m.SubjName) + + for _, r := range txt.Words(strings.ToLower(m.SubjName)) { + if len(r) > 1 { + remaining = strings.ReplaceAll(remaining, r, "") + } + } + + for _, r := range txt.Words(strings.ToLower(m.SubjAlias)) { + if len(r) > 1 { + remaining = strings.ReplaceAll(remaining, r, "") + } + } + } + + result = append(result, strings.Join(subj, txt.Or)) + } + + return result, names, txt.NormalizeQuery(remaining) +} diff --git a/internal/search/subjects_results.go b/internal/search/subjects_results.go new file mode 100644 index 000000000..44bb8c309 --- /dev/null +++ b/internal/search/subjects_results.go @@ -0,0 +1,21 @@ +package search + +// Subject represents a subject search result. +type Subject struct { + SubjUID string `json:"UID"` + MarkerUID string `json:"MarkerUID"` + MarkerSrc string `json:"MarkerSrc,omitempty"` + SubjType string `json:"Type"` + SubjSlug string `json:"Slug"` + SubjName string `json:"Name"` + SubjAlias string `json:"Alias"` + SubjFavorite bool `json:"Favorite"` + SubjPrivate bool `json:"Private"` + SubjExcluded bool `json:"Excluded"` + FileCount int `json:"FileCount"` + FileHash string `json:"FileHash"` + CropArea string `json:"CropArea"` +} + +// SubjectResults represents subject search results. +type SubjectResults []Subject diff --git a/internal/query/subject_search_test.go b/internal/search/subjects_test.go similarity index 70% rename from internal/query/subject_search_test.go rename to internal/search/subjects_test.go index 1d32f5bc9..2ccaed47a 100644 --- a/internal/query/subject_search_test.go +++ b/internal/search/subjects_test.go @@ -1,21 +1,19 @@ -package query +package search import ( "testing" - "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" "github.com/stretchr/testify/assert" ) -func TestSubjectSearch(t *testing.T) { +func TestSubjects(t *testing.T) { t.Run("FindAll", func(t *testing.T) { - results, err := SubjectSearch(form.SubjectSearch{Type: entity.SubjPerson}) + results, err := Subjects(form.SubjectSearch{Type: entity.SubjPerson}) assert.NoError(t, err) // t.Logf("Subjects: %#v", results) assert.LessOrEqual(t, 3, len(results)) }) - } diff --git a/internal/server/routes.go b/internal/server/routes.go index e94dd393f..0cd6340ac 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -42,20 +42,28 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { // JSON-REST API Version 1 v1 := router.Group(conf.BaseUri(config.ApiUri)) { - api.GetStatus(v1) - api.GetErrors(v1) - + // Config options. api.GetConfig(v1) api.GetConfigOptions(v1) api.SaveConfigOptions(v1) + // User profile and settings. api.GetSettings(v1) api.SaveSettings(v1) - api.ChangePassword(v1) api.CreateSession(v1) api.DeleteSession(v1) + // External account management. + api.SearchAccounts(v1) + api.GetAccount(v1) + api.GetAccountFolders(v1) + api.ShareWithAccount(v1) + api.CreateAccount(v1) + api.DeleteAccount(v1) + api.UpdateAccount(v1) + + // Thumbnails and downloads. api.GetThumb(v1) api.GetThumbCrop(v1) api.GetDownload(v1) @@ -63,11 +71,12 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.CreateZip(v1) api.DownloadZip(v1) - api.GetGeo(v1) + // Photos. + api.SearchPhotos(v1) + api.SearchPhotosGeo(v1) api.GetPhoto(v1) api.GetPhotoYaml(v1) api.UpdatePhoto(v1) - api.GetPhotos(v1) api.GetPhotoDownload(v1) api.GetPhotoLinks(v1) api.CreatePhotoLink(v1) @@ -87,47 +96,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.PhotoPrimary(v1) api.PhotoUnstack(v1) - api.Upload(v1) - api.StartImport(v1) - api.CancelImport(v1) - api.StartIndexing(v1) - api.CancelIndexing(v1) - - api.BatchPhotosApprove(v1) - api.BatchPhotosArchive(v1) - api.BatchPhotosRestore(v1) - api.BatchPhotosPrivate(v1) - api.BatchPhotosDelete(v1) - api.BatchAlbumsDelete(v1) - api.BatchLabelsDelete(v1) - - api.GetSubjects(v1) - api.GetSubject(v1) - api.UpdateSubject(v1) - api.LikeSubject(v1) - api.DislikeSubject(v1) - - api.LabelCover(v1) - api.GetLabels(v1) - api.UpdateLabel(v1) - api.GetLabelLinks(v1) - api.CreateLabelLink(v1) - api.UpdateLabelLink(v1) - api.DeleteLabelLink(v1) - api.LikeLabel(v1) - api.DislikeLabel(v1) - - api.FolderCover(v1) - api.GetFoldersOriginals(v1) - api.GetFoldersImport(v1) - - api.AlbumCover(v1) + // Albums. + api.SearchAlbums(v1) api.GetAlbum(v1) + api.AlbumCover(v1) api.CreateAlbum(v1) api.UpdateAlbum(v1) api.DeleteAlbum(v1) api.DownloadAlbum(v1) - api.GetAlbums(v1) api.GetAlbumLinks(v1) api.CreateAlbumLink(v1) api.UpdateAlbumLink(v1) @@ -138,18 +114,55 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.AddPhotosToAlbum(v1) api.RemovePhotosFromAlbum(v1) - api.GetAccounts(v1) - api.GetAccount(v1) - api.GetAccountFolders(v1) - api.ShareWithAccount(v1) - api.CreateAccount(v1) - api.DeleteAccount(v1) - api.UpdateAccount(v1) + // Labels. + api.SearchLabels(v1) + api.LabelCover(v1) + api.UpdateLabel(v1) + api.GetLabelLinks(v1) + api.CreateLabelLink(v1) + api.UpdateLabelLink(v1) + api.DeleteLabelLink(v1) + api.LikeLabel(v1) + api.DislikeLabel(v1) - api.SendFeedback(v1) + // Folders. + api.FolderCover(v1) + api.GetFoldersOriginals(v1) + api.GetFoldersImport(v1) + // People and other subjects. + api.SearchSubjects(v1) + api.GetSubject(v1) + api.UpdateSubject(v1) + api.LikeSubject(v1) + api.DislikeSubject(v1) + + // Faces. + api.SearchFaces(v1) + api.GetFace(v1) + api.UpdateFace(v1) + + // Indexing and importing. + api.Upload(v1) + api.StartImport(v1) + api.CancelImport(v1) + api.StartIndexing(v1) + api.CancelIndexing(v1) + + // Batch operations. + api.BatchPhotosApprove(v1) + api.BatchPhotosArchive(v1) + api.BatchPhotosRestore(v1) + api.BatchPhotosPrivate(v1) + api.BatchPhotosDelete(v1) + api.BatchAlbumsDelete(v1) + api.BatchLabelsDelete(v1) + + // Other. api.GetSvg(v1) - + api.GetStatus(v1) + api.GetErrors(v1) + api.SendFeedback(v1) api.Websocket(v1) } diff --git a/internal/workers/share.go b/internal/workers/share.go index 4d67f73a4..89d94ff25 100644 --- a/internal/workers/share.go +++ b/internal/workers/share.go @@ -13,6 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/remote" "github.com/photoprism/photoprism/internal/remote/webdav" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/thumb" ) @@ -53,7 +54,7 @@ func (worker *Share) Start() (err error) { } // Find accounts for which sharing is enabled - accounts, err := query.AccountSearch(f) + accounts, err := search.Accounts(f) // Upload newly shared files for _, a := range accounts { diff --git a/internal/workers/sync.go b/internal/workers/sync.go index 993c9b37f..be4415857 100644 --- a/internal/workers/sync.go +++ b/internal/workers/sync.go @@ -10,8 +10,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/mutex" - "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/remote" + "github.com/photoprism/photoprism/internal/search" ) // Sync represents a sync worker. @@ -59,7 +59,7 @@ func (worker *Sync) Start() (err error) { Sync: true, } - accounts, err := query.AccountSearch(f) + accounts, err := search.Accounts(f) for _, a := range accounts { if a.AccType != remote.ServiceWebDAV { diff --git a/pkg/s2/prefix.go b/pkg/s2/prefix.go index 719e98531..f81aa773f 100644 --- a/pkg/s2/prefix.go +++ b/pkg/s2/prefix.go @@ -36,7 +36,7 @@ func PrefixedToken(lat, lng float64) string { return Prefix(Token(lat, lng)) } -// Range returns a token range for finding nearby locations. +// PrefixedRange returns a token range for finding nearby locations. func PrefixedRange(token string, levelUp int) (min, max string) { min, max = Range(token, levelUp) diff --git a/pkg/txt/query.go b/pkg/txt/query.go new file mode 100644 index 000000000..9ca481c30 --- /dev/null +++ b/pkg/txt/query.go @@ -0,0 +1,38 @@ +package txt + +import ( + "strings" +) + +const ( + Or = "|" + And = "&" + Plus = " + " + OrEn = " or " + AndEn = " and " + WithEn = " with " + InEn = " in " + AtEn = " at " + Space = " " + Empty = "" +) + +// NormalizeQuery replaces search operator with default symbols. +func NormalizeQuery(s string) string { + s = strings.ToLower(Clip(s, ClipQuery)) + s = strings.ReplaceAll(s, OrEn, Or) + s = strings.ReplaceAll(s, AndEn, And) + s = strings.ReplaceAll(s, WithEn, And) + s = strings.ReplaceAll(s, InEn, And) + s = strings.ReplaceAll(s, AtEn, And) + s = strings.ReplaceAll(s, Plus, And) + s = strings.ReplaceAll(s, "%", "*") + return strings.Trim(s, "+&|_-=!@$%^(){}\\<>,.;: ") +} + +// QueryTooShort tests if a search query is too short. +func QueryTooShort(q string) bool { + q = strings.Trim(q, "- '") + + return q != Empty && len(q) < 3 && IsLatin(q) +} diff --git a/pkg/txt/query_test.go b/pkg/txt/query_test.go new file mode 100644 index 000000000..1ea505252 --- /dev/null +++ b/pkg/txt/query_test.go @@ -0,0 +1,29 @@ +package txt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeQuery(t *testing.T) { + t.Run("Replace", func(t *testing.T) { + q := NormalizeQuery("table spoon & usa | img% json OR BILL!") + assert.Equal(t, "table spoon & usa | img* json|bill", q) + }) +} + +func TestQueryTooShort(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + assert.False(t, QueryTooShort("")) + }) + t.Run("IsTooShort", func(t *testing.T) { + assert.True(t, QueryTooShort("aa")) + }) + t.Run("Chinese", func(t *testing.T) { + assert.False(t, QueryTooShort("李")) + }) + t.Run("OK", func(t *testing.T) { + assert.False(t, QueryTooShort("foo")) + }) +}