People: Add autocomplete for selecting a person #22

This commit is contained in:
Michael Mayer
2021-09-03 16:14:09 +02:00
parent 68f21146ba
commit c520cb4ee4
35 changed files with 992 additions and 682 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,7 @@ export default class Config {
Event.subscribe("config.updated", (ev, data) => this.setValues(data.config));
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
Event.subscribe("people", (ev, data) => this.onPeople(ev, data));
if (this.has("settings")) {
this.setTheme(this.get("settings").ui.theme);
@@ -134,6 +135,53 @@ export default class Config {
return this;
}
onPeople(ev, data) {
const type = ev.split(".")[1];
if (this.debug) {
console.log(ev, data);
}
if (!this.values.people) {
this.values.people = [];
}
switch (type) {
case "created":
this.values.people.push(...data.entities);
break;
case "updated":
for (let i = 0; i < data.entities.length; i++) {
const values = data.entities[i];
this.values.people
.filter((m) => m.UID === values.UID)
.forEach((m) => {
for (let key in values) {
if (
key !== "UID" &&
values.hasOwnProperty(key) &&
values[key] != null &&
typeof values[key] !== "object"
) {
m[key] = values[key];
}
}
});
}
break;
case "deleted":
for (let i = 0; i < data.entities.length; i++) {
const index = this.values.people.findIndex((m) => m.UID === data.entities[i]);
if (index >= 0) {
this.values.people.splice(index, 1);
}
}
break;
}
}
onCount(ev, data) {
const type = ev.split(".")[1];

View File

@@ -360,6 +360,6 @@ body.chrome #photoprism .search-results .result {
text-align: center;
}
#photoprism .face-results .invalid {
#photoprism .face-results .is-marker.is-invalid {
opacity: 0.5;
}

View File

@@ -17,12 +17,12 @@
<v-flex
v-for="(marker, index) in markers"
:key="index"
xs6 sm4 md3 lg2 xl1 d-flex
xs6 md3 xl2 d-flex
>
<v-card tile
:data-id="marker.UID"
style="user-select: none"
:class="{invalid: marker.Invalid}"
style="user-select: none;"
:class="marker.classes()"
class="result accent lighten-3">
<div class="card-background accent lighten-3"></div>
<canvas :id="'face-' + marker.UID" :key="marker.UID" width="300" height="300" style="width: 100%"
@@ -47,7 +47,7 @@
</v-btn>
</v-flex>
</v-layout>
<v-layout v-else row wrap align-center>
<v-layout v-else-if="marker.SubjectUID" row wrap align-center>
<v-flex xs12 class="text-xs-left pa-0">
<v-text-field
v-model="marker.Name"
@@ -59,12 +59,41 @@
single-line
solo-inverted
clearable
clear-icon="eject"
@click:clear="clearSubject(marker)"
@change="rename(marker)"
@keyup.enter.native="rename(marker)"
></v-text-field>
</v-flex>
</v-layout>
<v-layout v-else row wrap align-center>
<v-flex xs12 class="text-xs-left pa-0">
<v-combobox
v-model="marker.Name"
style="z-index: 250"
:items="$config.values.people"
item-value="Name"
item-text="Name"
:disabled="busy"
:return-object="false"
:menu-props="menuProps"
:allow-overflow="false"
:filter="filterSubjects"
:hint="$gettext('Name')"
hide-details
single-line
solo-inverted
open-on-clear
append-icon=""
prepend-inner-icon="person_add"
browser-autocomplete="off"
class="input-name pa-0 ma-0"
@change="rename(marker)"
@keyup.enter.native="rename(marker)"
>
</v-combobox>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-flex>
@@ -89,6 +118,7 @@ export default {
disabled: !this.$config.feature("edit"),
config: this.$config.values,
readonly: this.$config.get("readonly"),
menuProps:{"closeOnClick":false, "closeOnContentClick":true, "openOnClick":false, "maxHeight":300},
textRule: (v) => {
if (!v || !v.length) {
return this.$gettext("Name");
@@ -148,6 +178,15 @@ export default {
this.busy = true;
marker.approve().finally(() => this.busy = false);
},
filterSubjects(item, queryText) {
if (!item || !item.Keywords) {
return false;
}
const q = queryText.toLocaleLowerCase();
return item.Keywords.findIndex((w) => w.indexOf(q) > -1) > -1;
},
clearSubject(marker) {
this.busy = true;
marker.clearSubject(marker).finally(() => this.busy = false);

View File

@@ -20,8 +20,8 @@
deletable-chips multiple color="secondary-dark"
class="my-0 input-albums"
:items="albums"
item-text="Title"
item-value="UID"
item-text="Title"
:allow-overflow="false"
:label="$gettext('Select albums or create a new one')"
return-object

View File

@@ -40,10 +40,13 @@ export class Marker extends RestModel {
return {
UID: "",
FileUID: "",
FileHash: "",
FileArea: "",
Type: "",
Src: "",
Name: "",
Invalid: false,
Review: false,
X: 0.0,
Y: 0.0,
W: 0.0,
@@ -54,7 +57,6 @@ export class Marker extends RestModel {
SubjectUID: "",
Score: 0,
Size: 0,
Review: false,
};
}
@@ -80,9 +82,15 @@ export class Marker extends RestModel {
return this.Name;
}
thumbnailUrl(fileHash, size) {
if (fileHash && this.CropID) {
return `${config.contentUri}/t/${fileHash}/${config.previewToken()}/${size}_${this.CropID}`;
thumbnailUrl(size) {
if (!size) {
size = "crop_160";
}
if (this.FileHash && this.FileArea) {
return `${config.contentUri}/t/${this.FileHash}/${config.previewToken()}/${size}/${
this.FileArea
}`;
} else {
return `${config.contentUri}/svg/portrait`;
}
@@ -106,8 +114,8 @@ export class Marker extends RestModel {
rename() {
if (!this.Name || this.Name.trim() === "") {
// Don't update empty name.
return Promise.reject();
// Can't save an empty name.
return Promise.resolve(this);
}
this.SubjectSrc = src.Manual;

4
go.mod
View File

@@ -55,7 +55,7 @@ require (
github.com/stretchr/testify v1.7.0
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8
github.com/tensorflow/tensorflow v1.15.2
github.com/tidwall/gjson v1.8.1
github.com/tidwall/gjson v1.9.0
github.com/tidwall/pretty v1.2.0 // indirect
github.com/ugorji/go v1.2.6 // indirect
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
@@ -63,7 +63,7 @@ require (
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20210825183410-e898025ed96a
golang.org/x/net v0.0.0-20210902165921-8d991716f632
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect
golang.org/x/text v0.3.7 // indirect
gonum.org/v1/gonum v0.9.3

8
go.sum
View File

@@ -288,8 +288,8 @@ github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8 h1:ipNUBPHSUmH
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
github.com/tensorflow/tensorflow v1.15.2 h1:7/f/A664Tml/nRJg04+p3StcrsT53mkcvmxYHXI21Qo=
github.com/tensorflow/tensorflow v1.15.2/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
github.com/tidwall/gjson v1.8.1 h1:8j5EE9Hrh3l9Od1OIEDAb7IpezNA20UdRngNAj5N0WU=
github.com/tidwall/gjson v1.8.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
github.com/tidwall/gjson v1.9.0 h1:+Od7AE26jAaMgVC31cQV/Ope5iKXulNMflrlB7k+F9E=
github.com/tidwall/gjson v1.9.0/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE=
github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
@@ -385,8 +385,8 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210825183410-e898025ed96a h1:bRuuGXV8wwSdGTB+CtJf+FjgO1APK1CoO39T4BN/XBw=
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210902165921-8d991716f632 h1:900XJE4Rn/iPU+xD5ZznOe4GKKc4AdFK0IO1P6Z3/lQ=
golang.org/x/net v0.0.0-20210902165921-8d991716f632/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -108,6 +108,7 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
"albums.*",
"labels.*",
"subjects.*",
"people.*",
"sync.*",
)

View File

@@ -18,20 +18,20 @@ import (
// IndexCommand registers the index cli command.
var IndexCommand = cli.Command{
Name: "index",
Usage: "Indexes media files in originals folder",
UsageText: `To limit scope, a sub folder may be passed as first argument.`,
Usage: "indexes media files in the originals folder",
ArgsUsage: "[path]",
Flags: indexFlags,
Action: indexAction,
}
var indexFlags = []cli.Flag{
cli.BoolFlag{
Name: "all, a",
Name: "force, f",
Usage: "re-index all originals, including unchanged files",
},
cli.BoolFlag{
Name: "cleanup",
Usage: "removes orphan index entries and thumbnails",
Name: "cleanup, c",
Usage: "remove orphan index entries and thumbnails",
},
}

View File

@@ -4,7 +4,6 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/entity"
@@ -43,7 +42,7 @@ type ClientConfig struct {
Cameras entity.Cameras `json:"cameras"`
Lenses entity.Lenses `json:"lenses"`
Countries entity.Countries `json:"countries"`
Subjects query.SubjectResults `json:"subjects"`
People entity.People `json:"people"`
Thumbs ThumbTypes `json:"thumbs"`
Status string `json:"status"`
MapKey string `json:"mapKey"`
@@ -386,12 +385,6 @@ func (c *Config) UserConfig() ClientConfig {
Select("(COUNT(*) - 1) AS countries").
Take(&result.Count)
c.Db().
Table(entity.Subject{}.TableName()).
Select("SUM(deleted_at IS NULL) AS people").
Where("subject_src <> ? AND subject_type = ?", entity.SrcDefault, entity.SubjectPerson).
Take(&result.Count)
c.Db().
Table("places").
Select("SUM(photo_count > 0) AS places").
@@ -402,7 +395,9 @@ func (c *Config) UserConfig() ClientConfig {
Order("country_slug").
Find(&result.Countries)
result.Subjects, _ = query.SubjectSearch(form.SubjectSearch{Type: entity.SubjectPerson})
// People are subjects with type person.
result.Count.People, _ = query.PeopleCount()
result.People, _ = query.People()
c.Db().
Where("id IN (SELECT photos.camera_id FROM photos WHERE photos.photo_quality >= 0 OR photos.deleted_at IS NULL)").

View File

@@ -131,7 +131,6 @@ func CreateDefaultFixtures() {
CreateUnknownCountry()
CreateUnknownCamera()
CreateUnknownLens()
CreateUnknownFace()
}
// MigrateDb creates all tables and inserts default entities as needed.

View File

@@ -31,23 +31,9 @@ type Face struct {
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"`
}
// UnknownFace can be used as a placeholder for unknown faces.
var UnknownFace = Face{
ID: UnknownID,
FaceSrc: SrcDefault,
MatchedAt: TimePointer(),
SubjectUID: "",
EmbeddingJSON: []byte{},
}
// Faceless can be used as argument to match unmatched face markers.
var Faceless = []string{""}
// CreateUnknownFace initializes the database with a placeholder for unknown faces.
func CreateUnknownFace() {
_ = UnknownFace.Create()
}
// TableName returns the entity database table name.
func (Face) TableName() string {
return "faces_dev6"

View File

@@ -1,5 +1,14 @@
package entity
// UnknownFace can be used as a placeholder for unknown faces.
var UnknownFace = Face{
ID: UnknownID,
FaceSrc: SrcDefault,
MatchedAt: TimePointer(),
SubjectUID: "",
EmbeddingJSON: []byte{},
}
type FaceMap map[string]Face
func (m FaceMap) Get(name string) Face {

View File

@@ -406,7 +406,7 @@ func (m *File) AddFaces(faces face.Faces) {
// AddFace adds a face marker to the file.
func (m *File) AddFace(f face.Face, subjectUID string) {
marker := *NewFaceMarker(f, m.FileUID, subjectUID)
marker := *NewFaceMarker(f, *m, subjectUID)
if markers := m.Markers(); !markers.Contains(marker) {
markers.Append(marker)

View File

@@ -6,6 +6,8 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/crop"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/rnd"
@@ -26,14 +28,15 @@ const (
type Marker struct {
MarkerUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
FileUID string `gorm:"type:VARBINARY(42);index;" json:"FileUID" yaml:"FileUID"`
FileHash string `gorm:"type:VARBINARY(128);index" json:"FileHash" yaml:"FileHash,omitempty"`
FileArea string `gorm:"type:VARBINARY(16);default:''" json:"FileArea" yaml:"FileArea,omitempty"`
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"`
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
SubjectUID string `gorm:"type:VARBINARY(42);index;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
SubjectSrc string `gorm:"type:VARBINARY(8);default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
SubjectUID string `gorm:"type:VARBINARY(42);index:idx_markers_subject;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
SubjectSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subject;default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
subject *Subject `gorm:"foreignkey:SubjectUID;association_foreignkey:SubjectUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
CropID string `gorm:"type:VARBINARY(16);default:''" json:"CropID,omitempty" yaml:"CropID,omitempty"`
FaceID string `gorm:"type:VARBINARY(42);index;" json:"FaceID" yaml:"FaceID,omitempty"`
FaceDist float64 `gorm:"default:-1" json:"FaceDist" yaml:"FaceDist,omitempty"`
face *Face `gorm:"foreignkey:FaceID;association_foreignkey:ID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
@@ -52,9 +55,6 @@ type Marker struct {
UpdatedAt time.Time
}
// UnknownMarker can be used as a default for unknown markers.
var UnknownMarker = NewMarker("", "", SrcDefault, MarkerUnknown, 0, 0, 0, 0)
// TableName returns the entity database table name.
func (Marker) TableName() string {
return "markers_dev6"
@@ -70,35 +70,34 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error {
}
// NewMarker creates a new entity.
func NewMarker(fileUID, subjectUID, markerSrc, markerType string, x, y, w, h float32) *Marker {
func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType string) *Marker {
m := &Marker{
FileUID: fileUID,
SubjectUID: subjectUID,
FileUID: file.FileUID,
FileHash: file.FileHash,
FileArea: area.String(),
MarkerSrc: markerSrc,
MarkerType: markerType,
X: x,
Y: y,
W: w,
H: h,
SubjectUID: subjectUID,
X: area.X,
Y: area.Y,
W: area.W,
H: area.H,
MatchedAt: nil,
}
return m
}
// NewFaceMarker creates a new entity.
func NewFaceMarker(f face.Face, fileUID, subjectUID string) *Marker {
pos := f.Crop()
func NewFaceMarker(f face.Face, file File, subjectUID string) *Marker {
m := NewMarker(file, f.CropArea(), subjectUID, SrcImage, MarkerFace)
m := NewMarker(fileUID, subjectUID, SrcImage, MarkerFace, pos.X, pos.Y, pos.W, pos.H)
m.MatchedAt = nil
m.FaceDist = -1
m.CropID = f.Crop().ID()
m.EmbeddingsJSON = f.EmbeddingsJSON()
m.LandmarksJSON = f.RelativeLandmarksJSON()
m.Size = f.Size()
m.Score = f.Score
m.Review = f.Score < 30
m.FaceDist = -1
m.EmbeddingsJSON = f.EmbeddingsJSON()
m.LandmarksJSON = f.RelativeLandmarksJSON()
return m
}
@@ -372,27 +371,26 @@ func (m *Marker) Subject() (subj *Subject) {
// ClearSubject removes an existing subject association, and reports a collision.
func (m *Marker) ClearSubject(src string) error {
// Find the matching face.
if m.face == nil {
m.face = FindFace(m.FaceID)
}
if m.face == nil {
// Do nothing
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
// Update index & resolve collisions.
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "SubjectSrc": src}); err != nil {
return err
} else if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "SubjectSrc": src}); err != nil {
} else if m.face == nil {
m.subject = nil
return nil
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
return err
} else if resolved {
log.Debugf("faces: resolved collision with %s", m.face.ID)
}
// Clear references.
m.face = nil
m.MarkerName = ""
m.FaceID = ""
m.FaceDist = -1.0
m.SubjectUID = ""
m.SubjectSrc = src
m.subject = nil
return nil
}
@@ -495,7 +493,7 @@ func UpdateOrCreateMarker(m *Marker) (*Marker, error) {
err := result.Updates(map[string]interface{}{
"MarkerType": m.MarkerType,
"MarkerSrc": m.MarkerSrc,
"CropID": m.CropID,
"FileArea": m.FileArea,
"X": m.X,
"Y": m.Y,
"W": m.W,

View File

@@ -1,5 +1,10 @@
package entity
import "github.com/photoprism/photoprism/pkg/crop"
// UnknownMarker can be used as a default for unknown markers.
var UnknownMarker = NewMarker(File{}, crop.Area{}, "", SrcDefault, MarkerUnknown)
type MarkerMap map[string]Marker
func (m MarkerMap) Get(name string) Marker {
@@ -194,7 +199,7 @@ var MarkerFixtures = MarkerMap{
MarkerUID: "mt9k3pw1wowuy999",
FileUID: "ft2es49qhhinlple",
FaceID: FaceFixtures.Get("actress-1").ID,
CropID: "045038063041",
FileArea: "045038063041",
FaceDist: 0.26852392873736236,
SubjectSrc: SrcManual,
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
@@ -214,7 +219,7 @@ var MarkerFixtures = MarkerMap{
MarkerUID: "mt9k3pw1wowu1000",
FileUID: "ft2es49whhbnlqdn",
FaceID: FaceFixtures.Get("actress-1").ID,
CropID: "046045043065",
FileArea: "046045043065",
FaceDist: 0.4507357278575355,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
@@ -234,7 +239,7 @@ var MarkerFixtures = MarkerMap{
MarkerUID: "mt9k3pw1wowu1001",
FileUID: "ft8es39w45bnlqdw",
FaceID: FaceFixtures.Get("actress-1").ID,
CropID: "05403304060446",
FileArea: "05403304060446",
FaceDist: 0.5099754448545762,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,

View File

@@ -19,40 +19,42 @@ func (m *Marker) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
UID string
FileUID string
FileHash string
FileArea string
Type string
Src string
Name string
Invalid bool `json:",omitempty"`
X float32 `json:",omitempty"`
Y float32 `json:",omitempty"`
Invalid bool
Review bool
FaceID string
SubjectUID string
SubjectSrc string
X float32
Y float32
W float32 `json:",omitempty"`
H float32 `json:",omitempty"`
Size int `json:",omitempty"`
Score int `json:",omitempty"`
Review bool `json:",omitempty"`
CropID string `json:",omitempty"`
FaceID string `json:",omitempty"`
SubjectUID string `json:",omitempty"`
SubjectSrc string `json:",omitempty"`
CreatedAt time.Time
}{
UID: m.MarkerUID,
FileUID: m.FileUID,
FileHash: m.FileHash,
FileArea: m.FileArea,
Type: m.MarkerType,
Src: m.MarkerSrc,
Name: name,
Invalid: m.MarkerInvalid,
Review: m.Review,
FaceID: m.FaceID,
SubjectUID: m.SubjectUID,
SubjectSrc: m.SubjectSrc,
X: m.X,
Y: m.Y,
W: m.W,
H: m.H,
Size: m.Size,
Score: m.Score,
Review: m.Review,
CropID: m.CropID,
FaceID: m.FaceID,
SubjectUID: m.SubjectUID,
SubjectSrc: m.SubjectSrc,
CreatedAt: m.CreatedAt,
})
}

View File

@@ -3,17 +3,27 @@ package entity
import (
"testing"
"github.com/photoprism/photoprism/pkg/crop"
"github.com/photoprism/photoprism/internal/form"
"github.com/stretchr/testify/assert"
)
var testArea = crop.Area{
Name: "face",
X: 0.308333,
Y: 0.206944,
W: 0.355556,
H: 0.355556,
}
func TestMarker_TableName(t *testing.T) {
m := &Marker{}
assert.Contains(t, m.TableName(), "markers")
}
func TestNewMarker(t *testing.T) {
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel)
assert.IsType(t, &Marker{}, m)
assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID)
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID)
@@ -79,7 +89,7 @@ func TestMarker_SaveForm(t *testing.T) {
func TestUpdateOrCreateMarker(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel)
assert.IsType(t, &Marker{}, m)
assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID)
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID)
@@ -104,7 +114,7 @@ func TestUpdateOrCreateMarker(t *testing.T) {
func TestMarker_Updates(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
m, err := UpdateOrCreateMarker(m)
if err != nil {
@@ -129,7 +139,7 @@ func TestMarker_Updates(t *testing.T) {
func TestMarker_Update(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
m, err := UpdateOrCreateMarker(m)
if err != nil {
@@ -153,7 +163,8 @@ func TestMarker_Update(t *testing.T) {
func TestMarker_Save(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
m, err := UpdateOrCreateMarker(m)
if err != nil {

View File

@@ -3,13 +3,20 @@ package entity
import (
"testing"
"github.com/photoprism/photoprism/pkg/crop"
"github.com/stretchr/testify/assert"
)
var cropArea1 = crop.Area{Name: "face", X: 0.308333, Y: 0.206944, W: 0.355556, H: 0.355556}
var cropArea2 = crop.Area{Name: "face", X: 0.308313, Y: 0.206914, W: 0.655556, H: 0.655556}
var cropArea3 = crop.Area{Name: "face", X: 0.998133, Y: 0.816944, W: 0.0001, H: 0.0001}
var cropArea4 = crop.Area{Name: "face", X: 0.298133, Y: 0.216944, W: 0.255556, H: 0.155556}
func TestMarkers_Contains(t *testing.T) {
m1 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 0.308333, 0.206944, 0.355556, 0.355556)
m2 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 0.308313, 0.206914, 0.655556, 0.655556)
m3 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 0.998133, 0.816944, 0.0001, 0.0001)
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace)
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea2, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace)
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace)
m := Markers{m1}
@@ -18,9 +25,9 @@ func TestMarkers_Contains(t *testing.T) {
}
func TestMarkers_FaceCount(t *testing.T) {
m1 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 0.308333, 0.206944, 0.355556, 0.355556)
m2 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 0.298133, 0.216944, 0.255556, 0.155556)
m3 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 0.998133, 0.816944, 0.0001, 0.0001)
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace)
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace)
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace)
m3.MarkerInvalid = true
m := Markers{m1, m2, m3}

View File

@@ -13,10 +13,6 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
const (
SubjectPerson = "person"
)
var subjectMutex = sync.Mutex{}
// Subjects represents a list of subjects.
@@ -99,8 +95,21 @@ func (m *Subject) Create() error {
return Db().Create(m).Error
}
// Delete removes the entity from the database.
// Delete marks the entity as deleted in the database.
func (m *Subject) Delete() error {
if m.Deleted() {
return nil
}
log.Infof("subject: deleting %s %s", m.SubjectType, txt.Quote(m.SubjectName))
if m.IsPerson() {
event.EntitiesDeleted("people", []string{m.SubjectUID})
event.Publish("count.people", event.Data{
"count": -1,
})
}
return Db().Delete(m).Error
}
@@ -113,6 +122,17 @@ func (m *Subject) Deleted() bool {
func (m *Subject) Restore() error {
if m.Deleted() {
m.DeletedAt = nil
log.Infof("subject: restoring %s %s", m.SubjectType, txt.Quote(m.SubjectName))
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{
"count": 1,
})
}
return UnscopedDb().Model(m).Update("DeletedAt", nil).Error
}
@@ -140,9 +160,10 @@ func FirstOrCreateSubject(m *Subject) *Subject {
if found := FindSubjectByName(m.SubjectName); found != nil {
return found
} else if createErr := m.Create(); createErr == nil {
if !m.Excluded && m.SubjectType == SubjectPerson {
event.EntitiesCreated("people", []*Subject{m})
log.Infof("subject: added %s %s", m.SubjectType, txt.Quote(m.SubjectName))
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{
"count": 1,
})
@@ -200,6 +221,16 @@ func FindSubjectByName(s string) *Subject {
return &result
}
// IsPerson tests if the subject is a person.
func (m *Subject) IsPerson() bool {
return m.SubjectType == SubjectPerson
}
// Person creates and returns a Person based on this subject.
func (m *Subject) Person() *Person {
return NewPerson(*m)
}
// SetName changes the subject's name.
func (m *Subject) SetName(name string) error {
newName := txt.Clip(name, txt.ClipDefault)
@@ -219,6 +250,12 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
if err := m.SetName(name); err != nil {
return m, err
} else if err := m.Updates(Values{"SubjectName": m.SubjectName, "SubjectSlug": m.SubjectSlug}); err == nil {
log.Infof("subject: renamed %s %s", m.SubjectType, txt.Quote(m.SubjectName))
if m.IsPerson() {
event.EntitiesUpdated("people", []*Person{m.Person()})
}
return m, m.UpdateMarkerNames()
} else if existing := FindSubjectByName(m.SubjectName); existing == nil {
return m, err

View File

@@ -0,0 +1,53 @@
package entity
import (
"encoding/json"
"github.com/photoprism/photoprism/pkg/txt"
)
const (
SubjectPerson = "person"
)
// People represents a list of people.
type People []Person
// Person represents a subject with type person.
type Person struct {
SubjectUID string `json:"UID"`
SubjectName string `json:"Name"`
SubjectAlias string `json:"Alias,omitempty"`
Favorite bool `json:"Favorite,omitempty"`
Thumb string `json:",omitempty"`
}
// NewPerson returns a new entity.
func NewPerson(subj Subject) *Person {
result := &Person{
SubjectUID: subj.SubjectUID,
SubjectName: subj.SubjectName,
SubjectAlias: subj.SubjectAlias,
Favorite: subj.Favorite,
Thumb: subj.Thumb,
}
return result
}
// MarshalJSON returns the JSON encoding.
func (m *Person) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
UID string
Name string
Keywords []string `json:",omitempty"`
Favorite bool `json:",omitempty"`
Thumb string `json:",omitempty"`
}{
UID: m.SubjectUID,
Name: m.SubjectName,
Keywords: txt.NameKeywords(m.SubjectName, m.SubjectAlias),
Favorite: m.Favorite,
Thumb: m.Thumb,
})
}

View File

@@ -0,0 +1,40 @@
package entity
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewPerson(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
subj := Subject{
SubjectUID: "jqytw12v8jjeu3e6",
SubjectName: "William Henry Gates III",
SubjectAlias: "Windows Guru",
Favorite: true,
Thumb: "622c7287967f2800e873fbc55f0328973056ce1d",
}
m := NewPerson(subj)
assert.Equal(t, "jqytw12v8jjeu3e6", m.SubjectUID)
assert.Equal(t, "William Henry Gates III", m.SubjectName)
assert.Equal(t, "Windows Guru", m.SubjectAlias)
assert.Equal(t, true, m.Favorite)
assert.Equal(t, "622c7287967f2800e873fbc55f0328973056ce1d", m.Thumb)
if j, err := m.MarshalJSON(); err != nil {
t.Fatal(err)
} else {
s := string(j)
expected := "{\"UID\":\"jqytw12v8jjeu3e6\",\"Name\":\"William Henry Gates III\"," +
"\"Keywords\":[\"william\",\"henry\",\"gates\",\"iii\",\"windows\",\"guru\"]," +
"\"Favorite\":true,\"Thumb\":\"622c7287967f2800e873fbc55f0328973056ce1d\"}"
assert.Equal(t, expected, s)
t.Logf("person json: %s", s)
}
})
}

View File

@@ -12,7 +12,7 @@ func TestSubject_TableName(t *testing.T) {
assert.Contains(t, m.TableName(), "subjects")
}
func TestNewPerson(t *testing.T) {
func TestNewSubject(t *testing.T) {
t.Run("Jens_Mander", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjectPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjectName)

View File

@@ -123,8 +123,8 @@ func (f *Face) Dim() float32 {
return float32(1)
}
// Crop returns the relative image area for cropping.
func (f *Face) Crop() crop.Area {
// CropArea returns the relative image area for cropping.
func (f *Face) CropArea() crop.Area {
if f.Rows < 1 {
f.Cols = 1
}

View File

@@ -83,7 +83,7 @@ func TestDetect(t *testing.T) {
t.Logf("results: %#v", faces)
for i, f := range faces {
t.Logf("marker[%d]: %#v %#v", i, f.Crop(), f.Area)
t.Logf("marker[%d]: %#v %#v", i, f.CropArea(), f.Area)
t.Logf("landmarks[%d]: %s", i, f.RelativeLandmarksJSON())
img, err := tfInstance.getFaceCrop(fileName, fileHash, &faces[i])
@@ -249,7 +249,7 @@ func TestFace_EmbeddingsJSON(t *testing.T) {
})
}
func TestFace_Crop(t *testing.T) {
func TestFace_CropArea(t *testing.T) {
t.Run("Position", func(t *testing.T) {
f := Face{
Cols: 1000,
@@ -265,6 +265,6 @@ func TestFace_Crop(t *testing.T) {
Landmarks: nil,
Embeddings: nil,
}
t.Logf("marker: %#v", f.Crop())
t.Logf("marker: %#v", f.CropArea())
})
}

View File

@@ -131,7 +131,7 @@ func (t *Net) getFaceCrop(fileName, cacheHash string, f *Face) (img image.Image,
cacheFolder := t.getCacheFolder(fileName, cacheHash)
if cacheHash != "" {
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", cacheHash, CropSize, CropSize, f.Crop().ID())
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", cacheHash, CropSize, CropSize, f.CropArea().String())
} else {
base := filepath.Base(fileName)
i := strings.Index(base, "_")
@@ -140,7 +140,7 @@ func (t *Net) getFaceCrop(fileName, cacheHash string, f *Face) (img image.Image,
base = base[:i]
}
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", base, CropSize, CropSize, f.Crop().ID())
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", base, CropSize, CropSize, f.CropArea().String())
}
cacheFile := filepath.Join(cacheFolder, f.Thumb+fs.JpegExt)

View File

@@ -12,7 +12,7 @@ import (
// Faces returns all (known / unmatched) faces from the index.
func Faces(knownOnly, unmatched bool) (result entity.Faces, err error) {
stmt := Db().Where("face_src <> ?", entity.SrcDefault)
stmt := Db()
if unmatched {
stmt = stmt.Where("matched_at IS NULL")
@@ -72,7 +72,7 @@ func RemoveAnonymousFaceClusters() (removed int64, err error) {
// RemoveAutoFaceClusters removes automatically added face clusters from the index.
func RemoveAutoFaceClusters() (removed int64, err error) {
res := UnscopedDb().
Delete(entity.Face{}, "id <> ? AND face_src = ?", entity.UnknownFace.ID, entity.SrcAuto)
Delete(entity.Face{}, "face_src = ?", entity.SrcAuto)
return res.RowsAffected, res.Error
}

View File

@@ -11,7 +11,7 @@ import (
func UpdateAlbumDefaultPreviews() error {
return Db().Table(entity.Album{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT file_hash FROM files f
SELECT f.file_hash FROM files f
JOIN photos_albums pa ON pa.album_uid = albums.album_uid AND pa.photo_uid = f.photo_uid AND pa.hidden = 0
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
WHERE f.deleted_at IS NULL AND f.file_missing = 0 AND f.file_hash <> '' AND f.file_primary = 1 AND f.file_type = 'jpg'
@@ -23,7 +23,7 @@ func UpdateAlbumDefaultPreviews() error {
func UpdateAlbumFolderPreviews() error {
return Db().Table(entity.Album{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT file_hash FROM files f
SELECT f.file_hash FROM files f
JOIN photos p ON p.id = f.photo_id AND p.photo_path = albums.album_path AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
ORDER BY p.taken_at DESC LIMIT 1
@@ -35,7 +35,7 @@ func UpdateAlbumFolderPreviews() error {
func UpdateAlbumMonthPreviews() error {
return Db().Table(entity.Album{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT file_hash FROM files f
SELECT f.file_hash FROM files f
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
AND p.photo_year = albums.album_year AND p.photo_month = albums.album_month AND p.photo_month = albums.album_month
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
@@ -69,7 +69,7 @@ func UpdateLabelPreviews() (err error) {
// Labels.
if err = Db().Table(entity.Label{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT file_hash FROM files f
SELECT f.file_hash FROM files f
JOIN photos_labels pl ON pl.label_id = labels.id AND pl.photo_id = f.photo_id AND pl.uncertainty < 100
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
@@ -82,7 +82,7 @@ func UpdateLabelPreviews() (err error) {
// Categories.
if err = Db().Table(entity.Label{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT file_hash FROM files f
SELECT f.file_hash FROM files f
JOIN photos_labels pl ON pl.photo_id = f.photo_id AND pl.uncertainty < 100
JOIN categories c ON c.label_id = pl.label_id AND c.category_id = labels.id
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
@@ -98,8 +98,10 @@ func UpdateLabelPreviews() (err error) {
// UpdateSubjectPreviews updates subject preview images.
func UpdateSubjectPreviews() error {
/* Previous implementation for reference:
return Db().Table(entity.Subject{}.TableName()).
UpdateColumn("thumb", gorm.Expr("(SELECT file_hash FROM files f "+
UpdateColumn("thumb", gorm.Expr("(SELECT f.file_hash FROM files f "+
fmt.Sprintf(
"JOIN %s m ON f.file_uid = m.file_uid AND m.subject_uid = %s.subject_uid",
entity.Marker{}.TableName(),
@@ -108,7 +110,17 @@ func UpdateSubjectPreviews() error {
WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL AND f.file_hash <> '' AND p.deleted_at IS NULL
AND f.file_primary = 1 AND f.file_missing = 0 AND p.photo_private = 0 AND p.photo_quality > -1
ORDER BY p.taken_at DESC LIMIT 1)
WHERE thumb_src='' AND deleted_at IS NULL AND subject_src <> 'default'`)).
WHERE thumb_src='' AND deleted_at IS NULL`)).
Error */
return Db().Table(entity.Subject{}.TableName()).
UpdateColumn("thumb", gorm.Expr("(SELECT m.file_hash FROM "+
fmt.Sprintf(
"%s m WHERE m.subject_uid = %s.subject_uid AND m.subject_src = 'manual' ",
entity.Marker{}.TableName(),
entity.Subject{}.TableName())+
` AND m.file_hash <> '' AND ORDER BY m.size DESC LIMIT 1)
WHERE thumb_src='' AND deleted_at IS NULL`)).
Error
}

View File

@@ -10,6 +10,30 @@ import (
"github.com/photoprism/photoprism/internal/entity"
)
// People returns the sorted names of the first 2000 people.
func People() (people entity.People, err error) {
err = UnscopedDb().
Table(entity.Subject{}.TableName()).
Select("subject_uid, subject_name, subject_alias, favorite").
Where("deleted_at IS NULL AND subject_type = ?", entity.SubjectPerson).
Order("subject_name").
Limit(2000).Offset(0).
Scan(&people).Error
return people, err
}
// PeopleCount returns the total number of people in the index.
func PeopleCount() (count int, err error) {
err = Db().
Table(entity.Subject{}.TableName()).
Where("deleted_at IS NULL").
Where("subject_type = ?", entity.SubjectPerson).
Count(&count).Error
return count, err
}
// Subjects returns subjects from the index.
func Subjects(limit, offset int) (result entity.Subjects, err error) {
stmt := Db()
@@ -26,7 +50,7 @@ func SubjectMap() (result map[string]entity.Subject, err error) {
var subj entity.Subjects
stmt := Db().Where("subject_src <> ?", entity.SrcDefault)
stmt := Db()
if err = stmt.Find(&subj).Error; err != nil {
return result, err
@@ -111,7 +135,6 @@ func SearchSubjectUIDs(s string) (result []string, names []string, remaining str
var matches []Matches
stmt := Db().Model(entity.Subject{})
stmt = stmt.Where("subject_src <> ?", entity.SrcDefault)
if where := LikeAllNames(Cols{"subject_name", "subject_alias"}, s); len(where) == 0 {
return result, names, s

View File

@@ -9,6 +9,24 @@ import (
"github.com/stretchr/testify/assert"
)
func TestPeople(t *testing.T) {
if results, err := People(); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 3, len(results))
t.Logf("people: %#v", results)
}
}
func TestPeopleCount(t *testing.T) {
if result, err := PeopleCount(); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 3, result)
t.Logf("there are %d people", result)
}
}
func TestSubjects(t *testing.T) {
results, err := Subjects(3, 0)

View File

@@ -16,8 +16,8 @@ type Area struct {
H float32 `json:"h,omitempty"`
}
// ID returns a string identifying the approximate marker area.
func (m Area) ID() string {
// String returns a string identifying the approximate marker area.
func (m Area) String() string {
return fmt.Sprintf("%03d%03d%03d%03d", int(m.X*100), int(m.Y*100), int(m.W*100), int(m.H*100))
}

View File

@@ -6,25 +6,25 @@ import (
"github.com/stretchr/testify/assert"
)
func TestArea_ID(t *testing.T) {
func TestArea_String(t *testing.T) {
t.Run("082016010006_face", func(t *testing.T) {
m := NewArea("face", 0.822059, 0.167969, 0.1, 0.0664062)
assert.Equal(t, "082016010006", m.ID())
assert.Equal(t, "082016010006", m.String())
})
t.Run("082016010006_back", func(t *testing.T) {
m := NewArea("back", 0.822059, 0.167969, 0.1, 0.0664062)
assert.Equal(t, "082016010006", m.ID())
assert.Equal(t, "082016010006", m.String())
})
t.Run("020100003000", func(t *testing.T) {
m := NewArea("face", 0.201, 1.000, 0.03, 0.00000001)
assert.Equal(t, "020100003000", m.ID())
assert.Equal(t, "020100003000", m.String())
})
t.Run("000100000000", func(t *testing.T) {
m := NewArea("face", 0.0001, 1.000, 0, 0.00000001)
assert.Equal(t, "000100000000", m.ID())
assert.Equal(t, "000100000000", m.String())
})
t.Run("000012000100", func(t *testing.T) {
m := NewArea("", -2.0001, 0.123, -0.1, 4.00000001)
assert.Equal(t, "000012000100", m.ID())
assert.Equal(t, "000012000100", m.String())
})
}

View File

@@ -35,3 +35,15 @@ func JoinNames(names []string) string {
return fmt.Sprintf("%s & %s", strings.Join(names[:l-1], ", "), names[l-1])
}
}
// NameKeywords returns a list of unique, lowercase keywords based on a person's names and aliases.
func NameKeywords(names, aliases string) (results []string) {
if names == "" && aliases == "" {
return []string{}
}
names = strings.ToLower(names)
aliases = strings.ToLower(aliases)
return UniqueNames(append(Words(names), Words(aliases)...))
}

View File

@@ -43,3 +43,10 @@ func TestJoinNames(t *testing.T) {
assert.Equal(t, "Jens Mander, Name 2, Name 3 & Name 4", result)
})
}
func TestNameKeywords(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
result := NameKeywords("William Henry Gates III", "Windows Guru")
assert.Equal(t, []string{"william", "henry", "gates", "iii", "windows", "guru"}, result)
})
}