People: Add faces API endpoint & JS model #22

This commit is contained in:
Michael Mayer 2021-09-18 15:32:39 +02:00
parent 8492efebcf
commit ed22f245db
73 changed files with 1635 additions and 918 deletions

View file

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

108
frontend/src/model/face.js Normal file
View file

@ -0,0 +1,108 @@
/*
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.org>
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 <https://www.gnu.org/licenses/>.
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;

View file

@ -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',

View file

@ -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:

View file

@ -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)
})

View file

@ -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)

View file

@ -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)
})

View file

@ -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")

View file

@ -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)

113
internal/api/face.go Normal file
View file

@ -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)
})
}

86
internal/api/face_test.go Normal file
View file

@ -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)
})
}

View file

@ -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())})

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
})

View file

@ -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)

View file

@ -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
}
if m.IsPerson() {
event.SuccessMsg(i18n.MsgPersonSaved)
} else {
event.SuccessMsg(i18n.MsgSubjectSaved)
}
c.JSON(http.StatusOK, m)
})

View file

@ -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.

View file

@ -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
}

View file

@ -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

View file

@ -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: "",

14
internal/form/face.go Normal file
View file

@ -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
}

View file

@ -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}
}

View file

@ -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)
})
}

View file

@ -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

View file

@ -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"`

View file

@ -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"),

View file

@ -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

View file

@ -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 {

14
internal/query/account.go Normal file
View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}

92
internal/search/albums.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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))
})
}

70
internal/search/faces.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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))
})
}

View file

@ -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

View file

@ -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"`

View file

@ -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)

View file

@ -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))

View file

@ -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)
})

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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"`

View file

@ -1,4 +1,4 @@
package query
package search
import (
"testing"

View file

@ -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)

View file

@ -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 != "" {

View file

@ -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{},

View file

@ -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)

69
internal/search/search.go Normal file
View file

@ -0,0 +1,69 @@
/*
Package search performs common index search queries.
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.org>
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 <https://www.gnu.org/licenses/>.
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()
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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))
})
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

38
pkg/txt/query.go Normal file
View file

@ -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)
}

29
pkg/txt/query_test.go Normal file
View file

@ -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"))
})
}