People: Add overview page with search and context menu #22

This commit is contained in:
Michael Mayer 2021-09-17 14:26:12 +02:00
parent 9260c93df0
commit ece15c6ade
55 changed files with 1463 additions and 578 deletions

View file

@ -146,6 +146,11 @@ export default class Config {
this.values.people = [];
}
if (!data || !data.entities) {
console.warn("empty event data", ev, data);
return;
}
switch (type) {
case "created":
this.values.people.unshift(...data.entities);

View file

@ -43,6 +43,7 @@ import PAlbumClipboard from "./album/clipboard.vue";
import PAlbumToolbar from "./album/toolbar.vue";
import PLabelClipboard from "./label/clipboard.vue";
import PFileClipboard from "./file/clipboard.vue";
import PSubjectClipboard from "./subject/clipboard.vue";
import PAboutFooter from "./footer.vue";
const components = {};
@ -63,6 +64,7 @@ components.install = (Vue) => {
Vue.component("PAlbumToolbar", PAlbumToolbar);
Vue.component("PLabelClipboard", PLabelClipboard);
Vue.component("PFileClipboard", PFileClipboard);
Vue.component("PSubjectClipboard", PSubjectClipboard);
Vue.component("PAboutFooter", PAboutFooter);
};

View file

@ -223,6 +223,20 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-show="$config.feature('people')" :to="{ name: 'people' }" class="nav-people" @click.stop="">
<v-list-tile-action :title="$gettext('People')">
<v-icon>person</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
<translate key="People">People</translate>
<span v-show="config.count.people > 0"
:class="`nav-count ${rtl ? '--rtl' : ''}`">{{ config.count.people }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-if="isMini" v-show="$config.feature('places')" :to="{ name: 'places' }" class="nav-places"
@click.stop="">
<v-list-tile-action :title="$gettext('Places')">

View file

@ -0,0 +1,121 @@
<template>
<div>
<v-container v-if="selection.length > 0" fluid class="pa-0">
<v-speed-dial
id="t-clipboard" v-model="expanded"
fixed
bottom
direction="top"
transition="slide-y-reverse-transition"
:right="!rtl"
:left="rtl"
:class="`p-clipboard ${!rtl ? '--ltr' : '--rtl'} p-subject-clipboard`"
>
<template #activator>
<v-btn
fab
dark
color="accent darken-2"
class="action-menu"
>
<v-icon v-if="selection.length === 0">menu</v-icon>
<span v-else class="count-clipboard">{{ selection.length }}</span>
</v-btn>
</template>
<v-btn
v-if="features.download"
fab dark small
:title="$gettext('Download')"
color="download"
class="action-download"
:disabled="selection.length !== 1"
@click.stop="download()"
>
<v-icon>get_app</v-icon>
</v-btn>
<v-btn
v-if="features.albums"
fab dark small
:title="$gettext('Add to album')"
color="album"
:disabled="selection.length === 0"
class="action-album"
@click.stop="dialog.album = true"
>
<v-icon>bookmark</v-icon>
</v-btn>
<v-btn
fab dark small
color="accent"
class="action-clear"
@click.stop="clearClipboard()"
>
<v-icon>clear</v-icon>
</v-btn>
</v-speed-dial>
</v-container>
<p-photo-album-dialog :show="dialog.album" @cancel="dialog.album = false"
@confirm="addToAlbum"></p-photo-album-dialog>
</div>
</template>
<script>
import Api from "common/api";
import Notify from "common/notify";
import download from "common/download";
import Photo from "../../model/photo";
export default {
name: 'PSubjectClipboard',
props: {
selection: Array,
refresh: Function,
clearSelection: Function,
},
data() {
return {
features: this.$config.settings().features,
expanded: false,
dialog: {
delete: false,
album: false,
edit: false,
},
rtl: this.$rtl,
};
},
methods: {
clearClipboard() {
this.clearSelection();
this.expanded = false;
},
addToAlbum(ppid) {
this.dialog.album = false;
Api.post(`albums/${ppid}/photos`, {"subjects": this.selection}).then(() => this.onAdded());
},
onAdded() {
this.clearClipboard();
},
download() {
if (this.selection.length !== 1) {
Notify.error(this.$gettext("You can only download one album"));
return;
}
Notify.success(this.$gettext("Downloading…"));
Api.post("zip", {"subjects": this.selection}).then(r => {
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
});
this.expanded = false;
},
onDownload(path) {
download(path, "photos.zip");
},
}
};
</script>

View file

@ -29,7 +29,7 @@
:transition="false"
aspect-ratio="1"
class="accent lighten-2">
<v-btn v-if="!marker.SubjectUID && !marker.Invalid" :ripple="false" :depressed="false" class="input-reject"
<v-btn v-if="!marker.SubjUID && !marker.Invalid" :ripple="false" :depressed="false" class="input-reject"
icon flat small absolute :title="$gettext('Remove')"
@click.stop.prevent="onReject(marker)">
<v-icon color="white" class="action-reject">clear</v-icon>
@ -43,11 +43,11 @@
large depressed block :round="false"
class="action-approve text-xs-center"
:title="$gettext('Approve')" @click.stop="onApprove(marker)">
<v-icon dark>check</v-icon>
<v-icon dark>undo</v-icon>
</v-btn>
</v-flex>
</v-layout>
<v-layout v-else-if="marker.SubjectUID" row wrap align-center>
<v-layout v-else-if="marker.SubjUID" row wrap align-center>
<v-flex xs12 class="text-xs-left pa-0">
<v-text-field
v-model="marker.Name"

View file

@ -53,8 +53,8 @@ export class Marker extends RestModel {
H: 0.0,
CropID: "",
FaceID: "",
SubjectSrc: "",
SubjectUID: "",
SubjSrc: "",
SubjUID: "",
Score: 0,
Size: 0,
};
@ -118,9 +118,9 @@ export class Marker extends RestModel {
return Promise.resolve(this);
}
this.SubjectSrc = src.Manual;
this.SubjSrc = src.Manual;
const payload = { SubjectSrc: this.SubjectSrc, Name: this.Name };
const payload = { SubjSrc: this.SubjSrc, Name: this.Name };
return Api.put(this.getEntityResource(), payload).then((resp) =>
Promise.resolve(this.setValues(resp.data))

View file

@ -1,31 +0,0 @@
<template>
<div>
<v-toolbar flat color="secondary" :dense="$vuetify.breakpoint.smAndDown">
<v-toolbar-title>
<translate>Not implemented yet</translate>
</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container>
<p>
Issues labeled <a href="https://github.com/photoprism/photoprism/labels/help%20wanted">help wanted</a> /
<a href="https://github.com/photoprism/photoprism/labels/easy">easy</a> can be good (first)
contributions.
Our <a href="https://github.com/photoprism/photoprism/wiki">Developer Guide</a> contains all information
necessary to get you started.
</p>
</v-container>
</div>
</template>
<script>
export default {
name: 'People',
data() {
return {};
},
methods: {}
};
</script>

View file

@ -163,7 +163,7 @@ export default {
},
methods: {
viewType() {
let queryParam = this.$route.query['view'];
let queryParam = this.$route.query["view"];
let storedType = window.localStorage.getItem("photo_view");
if (queryParam) {
@ -178,7 +178,7 @@ export default {
return 'cards';
},
sortOrder() {
let queryParam = this.$route.query['order'];
let queryParam = this.$route.query["order"];
let storedType = window.localStorage.getItem("photo_order");
if (queryParam) {
@ -188,7 +188,7 @@ export default {
return storedType;
}
return 'newest';
return "newest";
},
openLocation(index) {
const photo = this.results[index];

View file

@ -0,0 +1,555 @@
<template>
<div v-infinite-scroll="loadMore" class="p-page p-page-subjects" style="user-select: none"
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="1200"
:infinite-scroll-listen-for-event="'scrollRefresh'">
<v-form ref="form" class="p-people-search" lazy-validation dense @submit.prevent="updateQuery">
<v-toolbar flat color="secondary" :dense="$vuetify.breakpoint.smAndDown">
<v-text-field id="search"
v-model="filter.q"
class="pt-3 pr-3 input-search"
single-line
:label="$gettext('Search')"
prepend-inner-icon="search"
browser-autocomplete="off"
clearable
color="secondary-dark"
@click:clear="clearQuery"
@keyup.enter.native="updateQuery"
></v-text-field>
<v-spacer></v-spacer>
<v-btn icon class="action-reload" :title="$gettext('Reload')" @click.stop="refresh">
<v-icon>refresh</v-icon>
</v-btn>
</v-toolbar>
</v-form>
<v-container v-if="loading" fluid class="pa-4">
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
</v-container>
<v-container v-else fluid class="pa-0">
<p-subject-clipboard :refresh="refresh" :selection="selection"
:clear-selection="clearSelection"></p-subject-clipboard>
<p-scroll-top></p-scroll-top>
<v-container grid-list-xs fluid class="pa-2">
<v-card v-if="results.length === 0" class="no-results secondary-light lighten-1 ma-1" flat>
<v-card-title primary-title>
<div>
<h3 class="title ma-0 pa-0">
<translate>Couldn't find anything</translate>
</h3>
<p class="mt-4 mb-0 pa-0">
<translate>Try again using other filters or keywords.</translate>
</p>
</div>
</v-card-title>
</v-card>
<v-layout row wrap class="search-results subject-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(model, index) in results"
:key="index"
xs6 sm4 md3 lg2 xxl1 d-flex
>
<v-card tile
:data-uid="model.UID"
style="user-select: none"
class="result accent lighten-3"
:class="model.classes(selection.includes(model.UID))"
:to="model.route(view)"
@contextmenu.stop="onContextMenu($event, index)"
>
<div class="card-background accent lighten-3"></div>
<v-img
:src="model.thumbnailUrl('tile_500')"
:alt="model.Name"
:transition="false"
aspect-ratio="1"
style="user-select: none"
class="accent lighten-2 clickable"
@touchstart="input.touchStart($event, index)"
@touchend.prevent="onClick($event, index)"
@mousedown="input.mouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-btn :ripple="false"
icon flat absolute
class="input-select"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="onSelect($event, index)"
@touchmove.stop.prevent
@click.stop.prevent="onSelect($event, index)">
<v-icon color="white" class="select-on">check_circle</v-icon>
<v-icon color="white" class="select-off">radio_button_off</v-icon>
</v-btn>
<v-btn :ripple="false"
icon flat absolute
class="input-favorite"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="toggleLike($event, index)"
@touchmove.stop.prevent
@click.stop.prevent="toggleLike($event, index)">
<v-icon color="#FFD600" class="select-on">star</v-icon>
<v-icon color="white" class="select-off">star_border</v-icon>
</v-btn>
</v-img>
<v-card-title primary-title class="pa-3 card-details" style="user-select: none;" @click.stop.prevent="">
<v-edit-dialog
:return-value.sync="model.Name"
lazy
class="inline-edit"
@save="onSave(model)"
>
<span v-if="model.Name" class="body-2 ma-0">
{{ model.Name }}
</span>
<span v-else>
<v-icon>edit</v-icon>
</span>
<template #input>
<v-text-field
v-model="model.Name"
:rules="[titleRule]"
:label="$gettext('Name')"
color="secondary-dark"
single-line
autofocus
></v-text-field>
</template>
</v-edit-dialog>
</v-card-title>
<v-card-text primary-title class="pb-2 pt-0 card-details" style="user-select: none;"
@click.stop.prevent="">
<div v-if="model.Bio" class="caption mb-2" :title="$gettext('Bio')">
{{ model.Bio | truncate(100) }}
</div>
<div class="caption mb-2">
<button v-if="model.Files === 1">
<translate>Contains one entry.</translate>
</button>
<button v-else-if="model.Files > 0">
<translate :translate-params="{n: model.Files}">Contains %{n} entries.</translate>
</button>
</div>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</v-container>
</div>
</template>
<script>
import Subject from "model/subject";
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";
export default {
name: 'PPageSubjects',
props: {
staticFilter: Object
},
data() {
const query = this.$route.query;
const routeName = this.$route.name;
const q = query['q'] ? query['q'] : '';
const all = query['all'] ? query['all'] : '';
const order = this.sortOrder();
const filter = {q: q, all: all, order: order};
const settings = {};
return {
view: 'all',
config: this.$config.values,
subscriptions: [],
listen: false,
dirty: false,
results: [],
scrollDisabled: true,
loading: true,
batchSize: Subject.batchSize(),
offset: 0,
page: 0,
selection: [],
settings: settings,
filter: filter,
lastFilter: {},
routeName: routeName,
titleRule: v => v.length <= this.$config.get("clip") || this.$gettext("Name too long"),
input: new Input(),
lastId: "",
};
},
watch: {
'$route'() {
const query = this.$route.query;
this.filter.q = query["q"] ? query["q"] : "";
this.filter.all = query["all"] ? query["all"] : "";
this.filter.order = this.sortOrder();
this.lastFilter = {};
this.routeName = this.$route.name;
this.search();
}
},
created() {
this.search();
this.subscriptions.push(Event.subscribe("subjects", (ev, data) => this.onUpdate(ev, data)));
this.subscriptions.push(Event.subscribe("touchmove.top", () => this.refresh()));
this.subscriptions.push(Event.subscribe("touchmove.bottom", () => this.loadMore()));
},
destroyed() {
for (let i = 0; i < this.subscriptions.length; i++) {
Event.unsubscribe(this.subscriptions[i]);
}
},
methods: {
searchCount() {
const offset = parseInt(window.localStorage.getItem("subjects_offset"));
if(this.offset > 0 || !offset) {
return this.batchSize;
}
return offset + this.batchSize;
},
sortOrder() {
return "relevance";
},
setOffset(offset) {
this.offset = offset;
window.localStorage.setItem("subjects_offset", offset);
},
toggleLike(ev, index) {
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {
return;
}
const m = this.results[index];
if (!m) {
return;
}
m.toggleLike();
},
selectRange(rangeEnd, models) {
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {
console.warn("selectRange() - invalid arguments:", rangeEnd, models);
return;
}
let rangeStart = models.findIndex((m) => m.getId() === this.lastId);
if (rangeStart === -1) {
this.toggleSelection(models[rangeEnd].getId());
return 1;
}
if (rangeStart > rangeEnd) {
const newEnd = rangeStart;
rangeStart = rangeEnd;
rangeEnd = newEnd;
}
for (let i = rangeStart; i <= rangeEnd; i++) {
this.addSelection(models[i].getId());
}
return (rangeEnd - rangeStart) + 1;
},
onSelect(ev, index) {
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {
return;
}
if (ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(this.results[index].getId());
}
},
onClick(ev, index) {
const inputType = this.input.eval(ev, index);
const longClick = inputType === ClickLong;
if (inputType === InputInvalid) {
return;
}
if (longClick || this.selection.length > 0) {
if (longClick || ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(this.results[index].getId());
}
} else {
this.$router.push(this.results[index].route(this.view));
}
},
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
if (this.results[index]) {
this.selectRange(index, this.results);
}
}
},
onSave(m) {
m.update();
},
showAll() {
this.filter.all = "true";
this.updateQuery();
},
showImportant() {
this.filter.all = "";
this.updateQuery();
},
clearQuery() {
this.filter.q = '';
this.updateQuery();
},
addSelection(uid) {
const pos = this.selection.indexOf(uid);
if (pos === -1) {
if (this.selection.length >= MaxItems) {
Notify.warn(this.$gettext("Can't select more items"));
return;
}
this.selection.push(uid);
this.lastId = uid;
}
},
toggleSelection(uid) {
const pos = this.selection.indexOf(uid);
if (pos !== -1) {
this.selection.splice(pos, 1);
this.lastId = "";
} else {
if (this.selection.length >= MaxItems) {
Notify.warn(this.$gettext("Can't select more items"));
return;
}
this.selection.push(uid);
this.lastId = uid;
}
},
removeSelection(uid) {
const pos = this.selection.indexOf(uid);
if (pos !== -1) {
this.selection.splice(pos, 1);
this.lastId = "";
}
},
clearSelection() {
this.selection.splice(0, this.selection.length);
this.lastId = "";
},
loadMore() {
if (this.scrollDisabled) return;
this.scrollDisabled = true;
this.listen = false;
const count = this.dirty ? (this.page + 2) * this.batchSize : this.batchSize;
const offset = this.dirty ? 0 : this.offset;
const params = {
count: count,
offset: offset,
};
Object.assign(params, this.lastFilter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
Subject.search(params).then(resp => {
this.results = this.dirty ? resp.models : this.results.concat(resp.models);
this.scrollDisabled = (resp.count < resp.limit);
if (this.scrollDisabled) {
this.setOffset(resp.offset);
if (this.results.length > 1) {
this.$notify.info(this.$gettextInterpolate(this.$gettext("All %{n} people loaded"), {n: this.results.length}));
}
} else {
this.setOffset(resp.offset + resp.limit);
this.page++;
this.$nextTick(() => {
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
this.$emit("scrollRefresh");
}
});
}
}).catch(() => {
this.scrollDisabled = false;
}).finally(() => {
this.dirty = false;
this.loading = false;
this.listen = true;
});
},
updateQuery() {
this.filter.q = this.filter.q.trim();
const query = {
view: this.settings.view
};
Object.assign(query, this.filter);
for (let key in query) {
if (query[key] === undefined || !query[key]) {
delete query[key];
}
}
if (JSON.stringify(this.$route.query) === JSON.stringify(query)) {
return;
}
this.$router.replace({query: query});
},
searchParams() {
const params = {
count: this.searchCount(),
offset: this.offset,
};
Object.assign(params, this.filter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
return params;
},
refresh() {
if (this.loading) return;
this.loading = true;
this.page = 0;
this.dirty = true;
this.scrollDisabled = false;
this.loadMore();
},
search() {
this.scrollDisabled = true;
// Don't query the same data more than once
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
this.$nextTick(() => this.$emit("scrollRefresh"));
return;
}
Object.assign(this.lastFilter, this.filter);
this.offset = 0;
this.page = 0;
this.loading = true;
this.listen = false;
const params = this.searchParams();
Subject.search(params).then(resp => {
this.offset = resp.limit;
this.results = resp.models;
this.scrollDisabled = (resp.count < resp.limit);
if (this.scrollDisabled) {
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), {n: this.results.length}));
} else {
this.$notify.info(this.$gettext('More than 20 people found'));
this.$nextTick(() => {
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
this.$emit("scrollRefresh");
}
});
}
}).finally(() => {
this.dirty = false;
this.loading = false;
this.listen = true;
});
},
onUpdate(ev, data) {
if (!this.listen) return;
if (!data || !data.entities) {
return;
}
console.log("onUpdate", ev, data);
const type = ev.split('.')[1];
switch (type) {
case 'updated':
for (let i = 0; i < data.entities.length; i++) {
const values = data.entities[i];
const model = this.results.find((m) => m.UID === values.UID);
if (model) {
for (let key in values) {
if (values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
model[key] = values[key];
}
}
}
}
break;
case 'deleted':
this.dirty = true;
for (let i = 0; i < data.entities.length; i++) {
const uid = data.entities[i];
const index = this.results.findIndex((m) => m.UID === uid);
if (index >= 0) {
this.results.splice(index, 1);
}
this.removeSelection(uid);
}
break;
case 'created':
this.dirty = true;
break;
default:
console.warn("unexpected event type", ev);
}
}
},
};
</script>

View file

@ -35,7 +35,7 @@ import Places from "pages/places.vue";
import Files from "pages/library/files.vue";
import Errors from "pages/library/errors.vue";
import Labels from "pages/labels.vue";
import People from "pages/people.vue";
import Subjects from "pages/subjects.vue";
import Library from "pages/library.vue";
import Settings from "pages/settings.vue";
import Login from "pages/login.vue";
@ -261,7 +261,7 @@ export default [
{
name: "people",
path: "/people",
component: People,
component: Subjects,
meta: { title: $gettext("People"), auth: true },
},
{

View file

@ -53,3 +53,16 @@ func PublishLabelEvent(e EntityEvent, uid string, c *gin.Context) {
event.PublishEntities("labels", string(e), result)
}
func PublishSubjectEvent(e EntityEvent, uid string, c *gin.Context) {
f := form.SubjectSearch{ID: uid}
result, err := query.SubjectSearch(f)
if err != nil {
log.Error(err)
AbortUnexpected(c)
return
}
event.PublishEntities("subjects", string(e), result)
}

View file

@ -14,6 +14,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// GetLabels finds and returns labels as JSON.
//
// GET /api/v1/labels
func GetLabels(router *gin.RouterGroup) {
router.GET("/labels", func(c *gin.Context) {
@ -49,6 +51,8 @@ func GetLabels(router *gin.RouterGroup) {
})
}
// UpdateLabel updates label properties.
//
// PUT /api/v1/labels/:uid
func UpdateLabel(router *gin.RouterGroup) {
router.PUT("/labels/:uid", func(c *gin.Context) {
@ -85,6 +89,8 @@ func UpdateLabel(router *gin.RouterGroup) {
})
}
// LikeLabel flags a label as favorite.
//
// POST /api/v1/labels/:uid/like
//
// Parameters:
@ -123,6 +129,8 @@ func LikeLabel(router *gin.RouterGroup) {
})
}
// DislikeLabel removes the favorite flag from a label.
//
// DELETE /api/v1/labels/:uid/like
//
// Parameters:

View file

@ -93,7 +93,7 @@ func UpdateMarker(router *gin.RouterGroup) {
log.Errorf("photo: %s (save marker form)", err)
AbortSaveFailed(c)
return
} else if marker.SubjectUID != "" && marker.SubjectSrc == entity.SrcManual && marker.FaceID != "" {
} else if marker.SubjUID != "" && marker.SubjSrc == entity.SrcManual && marker.FaceID != "" {
if res, err := service.Faces().Optimize(); err != nil {
log.Errorf("faces: %s (optimize)", err)
} else if res.Merged > 0 {

View file

@ -35,7 +35,7 @@ func TestUpdateMarker(t *testing.T) {
u := fmt.Sprintf("/api/v1/markers/%s", markerUID)
var m = form.Marker{
SubjectSrc: "manual",
SubjSrc: "manual",
MarkerInvalid: true,
MarkerName: "Foo",
}
@ -100,7 +100,7 @@ func TestUpdateMarker(t *testing.T) {
UpdateMarker(router)
var m = form.Marker{
SubjectSrc: "manual",
SubjSrc: "manual",
MarkerInvalid: false,
MarkerName: "Actress A",
}
@ -125,7 +125,7 @@ func TestUpdateMarker(t *testing.T) {
UpdateMarker(router)
var m = form.Marker{
SubjectSrc: "manual",
SubjSrc: "manual",
MarkerInvalid: false,
MarkerName: "Actress A",
}
@ -150,20 +150,20 @@ func TestUpdateMarker(t *testing.T) {
UpdateMarker(router)
var m = struct {
ID int
Type string
Src int
Name int
SubjectUID string
SubjectSrc string
FaceID string
ID int
Type string
Src int
Name int
SubjUID string
SubjSrc string
FaceID string
}{ID: 8,
Type: "face",
Src: 123,
Name: 456,
SubjectUID: "jqy1y111h1njaaac",
SubjectSrc: "manual",
FaceID: "GMH5NISEEULNJL6RATITOA3TMZXMTMCI"}
Type: "face",
Src: 123,
Name: 456,
SubjUID: "jqy1y111h1njaaac",
SubjSrc: "manual",
FaceID: "GMH5NISEEULNJL6RATITOA3TMZXMTMCI"}
if b, err := json.Marshal(m); err != nil {
t.Fatal(err)
} else {

View file

@ -7,6 +7,7 @@ import (
"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/query"
@ -68,6 +69,111 @@ func GetSubject(router *gin.RouterGroup) {
} else {
c.JSON(http.StatusOK, subj)
}
})
}
// UpdateSubject updates subject properties.
//
// PUT /api/v1/subjects/:uid
func UpdateSubject(router *gin.RouterGroup) {
router.PUT("/subjects/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Subject
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
uid := c.Param("uid")
m := entity.FindSubject(uid)
if m == nil {
Abort(c, http.StatusNotFound, i18n.ErrSubjectNotFound)
return
}
if _, err := m.UpdateName(f.SubjName); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
event.SuccessMsg(i18n.MsgSubjectSaved)
c.JSON(http.StatusOK, m)
})
}
// LikeSubject flags a subject as favorite.
//
// POST /api/v1/subjects/:uid/like
//
// Parameters:
// uid: string Subject UID
func LikeSubject(router *gin.RouterGroup) {
router.POST("/subjects/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
subj := entity.FindSubject(uid)
if subj == nil {
Abort(c, http.StatusNotFound, i18n.ErrSubjectNotFound)
return
}
if err := subj.Update("SubjFavorite", true); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
PublishSubjectEvent(EntityUpdated, uid, c)
c.JSON(http.StatusOK, http.Response{})
})
}
// DislikeSubject removes the favorite flag from a subject.
//
// DELETE /api/v1/subjects/:uid/like
//
// Parameters:
// uid: string Subject UID
func DislikeSubject(router *gin.RouterGroup) {
router.DELETE("/subjects/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
subj := entity.FindSubject(uid)
if subj == nil {
Abort(c, http.StatusNotFound, i18n.ErrSubjectNotFound)
return
}
if err := subj.Update("SubjFavorite", false); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
return
}
PublishSubjectEvent(EntityUpdated, uid, c)
c.JSON(http.StatusOK, http.Response{})
})
}

View file

@ -21,6 +21,7 @@ var DeprecatedTables = Deprecated{
"subjects_dev5",
"subjects_dev6",
"subjects_dev7",
"subjects_dev8",
"markers_dev1",
"markers_dev2",
"markers_dev3",
@ -28,6 +29,7 @@ var DeprecatedTables = Deprecated{
"markers_dev5",
"markers_dev6",
"markers_dev7",
"markers_dev8",
"faces_dev1",
"faces_dev2",
"faces_dev3",
@ -35,4 +37,5 @@ var DeprecatedTables = Deprecated{
"faces_dev5",
"faces_dev6",
"faces_dev7",
"faces_dev8",
}

View file

@ -21,7 +21,7 @@ var faceMutex = sync.Mutex{}
type Face struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
FaceSrc string `gorm:"type:VARBINARY(8);" json:"Src" yaml:"Src,omitempty"`
SubjectUID string `gorm:"type:VARBINARY(42);index;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID" yaml:"SubjUID,omitempty"`
Samples int `json:"Samples" yaml:"Samples,omitempty"`
SampleRadius float64 `json:"SampleRadius" yaml:"SampleRadius,omitempty"`
Collisions int `json:"Collisions" yaml:"Collisions,omitempty"`
@ -38,14 +38,14 @@ var Faceless = []string{""}
// TableName returns the entity database table name.
func (Face) TableName() string {
return "faces_dev8"
return "faces_dev9"
}
// NewFace returns a new face.
func NewFace(subjectUID, faceSrc string, embeddings Embeddings) *Face {
func NewFace(subjUID, faceSrc string, embeddings Embeddings) *Face {
result := &Face{
SubjectUID: subjectUID,
FaceSrc: faceSrc,
SubjUID: subjUID,
FaceSrc: faceSrc,
}
if err := result.SetEmbeddings(embeddings); err != nil {
@ -145,7 +145,7 @@ func (m *Face) Match(embeddings Embeddings) (match bool, dist float64) {
// ResolveCollision resolves a collision with a different subject's face.
func (m *Face) ResolveCollision(embeddings Embeddings) (resolved bool, err error) {
if m.SubjectUID == "" {
if m.SubjUID == "" {
// Ignore reports for anonymous faces.
return false, nil
} else if m.ID == "" {
@ -165,9 +165,9 @@ func (m *Face) ResolveCollision(embeddings Embeddings) (resolved bool, err error
log.Infof("faces: %s collision at dist %f reported, same person?", m.ID, dist)
// Reset subject UID just in case.
m.SubjectUID = ""
m.SubjUID = ""
return false, m.Updates(Values{"SubjectUID": m.SubjectUID})
return false, m.Updates(Values{"SubjUID": m.SubjUID})
} else {
m.MatchedAt = nil
m.Collisions++
@ -242,20 +242,20 @@ func (m *Face) MatchMarkers(faceIds []string) error {
}
// SetSubjectUID updates the face's subject uid and related markers.
func (m *Face) SetSubjectUID(uid string) (err error) {
func (m *Face) SetSubjectUID(subjUID string) (err error) {
// Update face.
if err = m.Update("SubjectUID", uid); err != nil {
if err = m.Update("SubjUID", subjUID); err != nil {
return err
} else {
m.SubjectUID = uid
m.SubjUID = subjUID
}
// Update related markers.
if err = Db().Model(&Marker{}).
Where("face_id = ?", m.ID).
Where("subject_src = ?", SrcAuto).
Where("subject_uid <> ?", m.SubjectUID).
Updates(Values{"SubjectUID": m.SubjectUID, "Review": false}).Error; err != nil {
Where("subj_src = ?", SrcAuto).
Where("subj_uid <> ?", m.SubjUID).
Updates(Values{"SubjUID": m.SubjUID, "MarkerReview": false}).Error; err != nil {
return err
}
@ -309,12 +309,12 @@ func FirstOrCreateFace(m *Face) *Face {
result := Face{}
if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjectUID)
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjUID)
return &result
} else if createErr := m.Create(); createErr == nil {
return m
} else if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjectUID)
log.Warnf("faces: %s has ambiguous subject %s", m.ID, m.SubjUID)
return &result
} else {
log.Errorf("faces: %s when trying to create %s", createErr, m.ID)

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@ import (
func TestFaceMap_Get(t *testing.T) {
t.Run("get existing face", func(t *testing.T) {
r := FaceFixtures.Get("jane-doe")
assert.Equal(t, "jqy1y111h1njaaab", r.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaab", r.SubjUID)
assert.Equal(t, "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG7", r.ID)
assert.IsType(t, Face{}, r)
})
@ -23,7 +23,7 @@ func TestFaceMap_Get(t *testing.T) {
func TestFaceMap_Pointer(t *testing.T) {
t.Run("get existing face", func(t *testing.T) {
r := FaceFixtures.Pointer("jane-doe")
assert.Equal(t, "jqy1y111h1njaaab", r.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaab", r.SubjUID)
assert.Equal(t, "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG7", r.ID)
assert.IsType(t, &Face{}, r)
})

View file

@ -133,7 +133,7 @@ func TestNewFace(t *testing.T) {
r := NewFace("123", SrcAuto, e)
assert.Equal(t, "", r.FaceSrc)
assert.Equal(t, "123", r.SubjectUID)
assert.Equal(t, "123", r.SubjUID)
})
}
@ -185,7 +185,7 @@ func TestFace_Save(t *testing.T) {
assert.Nil(t, FindFace(m.ID))
m.Save()
assert.NotNil(t, FindFace(m.ID))
assert.Equal(t, "12345fde", FindFace(m.ID).SubjectUID)
assert.Equal(t, "12345fde", FindFace(m.ID).SubjUID)
}
func TestFace_Update(t *testing.T) {
@ -193,11 +193,11 @@ func TestFace_Update(t *testing.T) {
assert.Nil(t, FindFace(m.ID))
m.Save()
assert.NotNil(t, FindFace(m.ID))
assert.Equal(t, "12345fdef", FindFace(m.ID).SubjectUID)
assert.Equal(t, "12345fdef", FindFace(m.ID).SubjUID)
m2 := FindFace(m.ID)
m2.Update("SubjectUID", "new")
assert.Equal(t, "new", FindFace(m.ID).SubjectUID)
m2.Update("SubjUID", "new")
assert.Equal(t, "new", FindFace(m.ID).SubjUID)
}
func TestFace_RefreshPhotos(t *testing.T) {
@ -212,12 +212,12 @@ func TestFirstOrCreateFace(t *testing.T) {
t.Run("create new face", func(t *testing.T) {
m := NewFace("12345unique", SrcAuto, Embeddings{Embedding{99}, Embedding{2}})
r := FirstOrCreateFace(m)
assert.Equal(t, "12345unique", r.SubjectUID)
assert.Equal(t, "12345unique", r.SubjUID)
})
t.Run("return existing entity", func(t *testing.T) {
m := FaceFixtures.Pointer("joe-biden")
r := FirstOrCreateFace(m)
assert.Equal(t, "jqy3y652h8njw0sx", r.SubjectUID)
assert.Equal(t, "jqy3y652h8njw0sx", r.SubjUID)
assert.Equal(t, 33, r.Samples)
})
}

View file

@ -420,8 +420,8 @@ 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, subjectUID)
func (m *File) AddFace(f face.Face, subjUID string) {
marker := *NewFaceMarker(f, *m, subjUID)
if markers := m.Markers(); !markers.Contains(marker) {
markers.Append(marker)

View file

@ -34,9 +34,10 @@ type Marker struct {
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:idx_markers_subject_uid_src;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
SubjectSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subject_uid_src;default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
subject *Subject `gorm:"foreignkey:SubjectUID;association_foreignkey:SubjectUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
MarkerReview bool `json:"Review" yaml:"Review,omitempty"`
SubjUID string `gorm:"type:VARBINARY(42);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subj_uid_src;default:'';" json:"SubjSrc" yaml:"SubjSrc,omitempty"`
subject *Subject `gorm:"foreignkey:SubjUID;association_foreignkey:SubjUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
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"`
@ -49,7 +50,6 @@ type Marker struct {
H float32 `gorm:"type:FLOAT;" json:"H" yaml:"H,omitempty"`
Size int `gorm:"default:-1" json:"Size" yaml:"Size,omitempty"`
Score int `gorm:"type:SMALLINT" json:"Score" yaml:"Score,omitempty"`
Review bool `json:"Review" yaml:"Review,omitempty"`
MatchedAt *time.Time `sql:"index" json:"MatchedAt" yaml:"MatchedAt,omitempty"`
CreatedAt time.Time
UpdatedAt time.Time
@ -57,7 +57,7 @@ type Marker struct {
// TableName returns the entity database table name.
func (Marker) TableName() string {
return "markers_dev8"
return "markers_dev9"
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
@ -70,14 +70,14 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error {
}
// NewMarker creates a new entity.
func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType string) *Marker {
func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string) *Marker {
m := &Marker{
FileUID: file.FileUID,
FileHash: file.FileHash,
CropArea: area.String(),
MarkerSrc: markerSrc,
MarkerType: markerType,
SubjectUID: subjectUID,
SubjUID: subjUID,
X: area.X,
Y: area.Y,
W: area.W,
@ -89,12 +89,12 @@ func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType stri
}
// NewFaceMarker creates a new entity.
func NewFaceMarker(f face.Face, file File, subjectUID string) *Marker {
m := NewMarker(file, f.CropArea(), subjectUID, SrcImage, MarkerFace)
func NewFaceMarker(f face.Face, file File, subjUID string) *Marker {
m := NewMarker(file, f.CropArea(), subjUID, SrcImage, MarkerFace)
m.Size = f.Size()
m.Score = f.Score
m.Review = f.Score < 30
m.MarkerReview = f.Score < 30
m.FaceDist = -1
m.EmbeddingsJSON = f.EmbeddingsJSON()
m.LandmarksJSON = f.RelativeLandmarksJSON()
@ -121,13 +121,13 @@ func (m *Marker) SaveForm(f form.Marker) error {
changed = true
}
if m.Review != f.Review {
m.Review = f.Review
if m.MarkerReview != f.MarkerReview {
m.MarkerReview = f.MarkerReview
changed = true
}
if f.SubjectSrc == SrcManual && strings.TrimSpace(f.MarkerName) != "" {
m.SubjectSrc = SrcManual
if f.SubjSrc == SrcManual && strings.TrimSpace(f.MarkerName) != "" {
m.SubjSrc = SrcManual
m.MarkerName = txt.Title(txt.Clip(f.MarkerName, txt.ClipDefault))
if err := m.SyncSubject(true); err != nil {
@ -172,21 +172,21 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
}
// Any reason we don't want to set a new face for this marker?
if m.SubjectSrc == SrcAuto || f.SubjectUID == "" || m.SubjectUID == "" || f.SubjectUID == m.SubjectUID {
if m.SubjSrc == SrcAuto || f.SubjUID == "" || m.SubjUID == "" || f.SubjUID == m.SubjUID {
// Don't skip if subject wasn't set manually, or subjects match.
} else if reported, err := f.ResolveCollision(m.Embeddings()); err != nil {
return false, err
} else if reported {
log.Infof("faces: collision of marker %s, subject %s, face %s, subject %s, source %s", m.MarkerUID, m.SubjectUID, f.ID, f.SubjectUID, m.SubjectSrc)
log.Infof("faces: collision of marker %s, subject %s, face %s, subject %s, source %s", m.MarkerUID, m.SubjUID, f.ID, f.SubjUID, m.SubjSrc)
return false, nil
} else {
return false, nil
}
// Update face with known subject from marker?
if m.SubjectSrc == SrcAuto || m.SubjectUID == "" || f.SubjectUID != "" {
if m.SubjSrc == SrcAuto || m.SubjUID == "" || f.SubjUID != "" {
// Don't update if face has a known subject, or marker subject is unknown.
} else if err = f.SetSubjectUID(m.SubjectUID); err != nil {
} else if err = f.SetSubjectUID(m.SubjUID); err != nil {
return false, err
}
@ -194,7 +194,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
m.face = f
// Skip update if the same face is already set.
if m.SubjectUID == f.SubjectUID && m.FaceID == f.ID {
if m.SubjUID == f.SubjUID && m.FaceID == f.ID {
// Update matching timestamp.
m.MatchedAt = TimePointer()
return false, m.Updates(Values{"MatchedAt": m.MatchedAt})
@ -202,8 +202,8 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
// Remember current values for comparison.
faceID := m.FaceID
subjectUID := m.SubjectUID
SubjectSrc := m.SubjectSrc
subjUID := m.SubjUID
subjSrc := m.SubjSrc
m.FaceID = f.ID
m.FaceDist = dist
@ -211,7 +211,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
if m.FaceDist < 0 {
faceEmbedding := f.Embedding()
// Calculate smallest distance to embeddings.
// Calculate the smallest distance to embeddings.
for _, e := range m.Embeddings() {
if len(e) != len(faceEmbedding) {
continue
@ -223,8 +223,8 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
}
}
if f.SubjectUID != "" {
m.SubjectUID = f.SubjectUID
if f.SubjUID != "" {
m.SubjUID = f.SubjUID
}
if err = m.SyncSubject(false); err != nil {
@ -232,18 +232,18 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
}
// Update face subject?
if m.SubjectSrc == SrcAuto || m.SubjectUID == "" || f.SubjectUID == m.SubjectUID {
if m.SubjSrc == SrcAuto || m.SubjUID == "" || f.SubjUID == m.SubjUID {
// Not needed.
} else if err = f.SetSubjectUID(m.SubjectUID); err != nil {
} else if err = f.SetSubjectUID(m.SubjUID); err != nil {
return false, err
}
updated = m.FaceID != faceID || m.SubjectUID != subjectUID || m.SubjectSrc != SubjectSrc
updated = m.FaceID != faceID || m.SubjUID != subjUID || m.SubjSrc != subjSrc
// Update matching timestamp.
m.MatchedAt = TimePointer()
if err := m.Updates(Values{"FaceID": m.FaceID, "FaceDist": m.FaceDist, "SubjectUID": m.SubjectUID, "SubjectSrc": m.SubjectSrc, "Review": false, "MatchedAt": m.MatchedAt}); err != nil {
if err := m.Updates(Values{"FaceID": m.FaceID, "FaceDist": m.FaceDist, "SubjUID": m.SubjUID, "SubjSrc": m.SubjSrc, "MarkerReview": false, "MatchedAt": m.MatchedAt}); err != nil {
return false, err
} else if !updated {
return false, nil
@ -261,20 +261,20 @@ func (m *Marker) SyncSubject(updateRelated bool) (err error) {
subj := m.Subject()
if subj == nil || m.SubjectSrc == SrcAuto {
if subj == nil || m.SubjSrc == SrcAuto {
return nil
}
// Update subject with marker name?
if m.MarkerName == "" || subj.SubjectName == m.MarkerName {
if m.MarkerName == "" || subj.SubjName == m.MarkerName {
// Do nothing.
} else if subj, err = subj.UpdateName(m.MarkerName); err != nil {
return err
} else if subj != nil {
// Update subject fields in case it was merged.
m.subject = subj
m.SubjectUID = subj.SubjectUID
m.MarkerName = subj.SubjectName
m.SubjUID = subj.SubjUID
m.MarkerName = subj.SubjName
}
// Create known face for subject?
@ -285,21 +285,21 @@ func (m *Marker) SyncSubject(updateRelated bool) (err error) {
}
// Update related markers?
if m.FaceID == "" || m.SubjectUID == "" {
if m.FaceID == "" || m.SubjUID == "" {
// Do nothing.
} else if res := Db().Model(&Face{}).Where("id = ? AND subject_uid = ''", m.FaceID).Update("SubjectUID", m.SubjectUID); res.Error != nil {
} else if res := Db().Model(&Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("SubjUID", m.SubjUID); res.Error != nil {
return fmt.Errorf("%s (update known face)", err)
} else if !updateRelated {
return nil
} else if err := Db().Model(&Marker{}).
Where("marker_uid <> ?", m.MarkerUID).
Where("face_id = ?", m.FaceID).
Where("subject_src = ?", SrcAuto).
Where("subject_uid <> ?", m.SubjectUID).
Updates(Values{"SubjectUID": m.SubjectUID, "SubjectSrc": SrcAuto, "Review": false}).Error; err != nil {
Where("subj_src = ?", SrcAuto).
Where("subj_uid <> ?", m.SubjUID).
Updates(Values{"SubjUID": m.SubjUID, "SubjSrc": SrcAuto, "MarkerReview": false}).Error; err != nil {
return fmt.Errorf("%s (update related markers)", err)
} else if res.RowsAffected > 0 && m.face != nil {
log.Debugf("marker: matched %s with %s", subj.SubjectName, m.FaceID)
log.Debugf("marker: matched %s with %s", subj.SubjName, m.FaceID)
return m.face.RefreshPhotos()
}
@ -342,7 +342,7 @@ func (m *Marker) SubjectName() string {
if m.MarkerName != "" {
return m.MarkerName
} else if s := m.Subject(); s != nil {
return s.SubjectName
return s.SubjName
}
return ""
@ -351,27 +351,27 @@ func (m *Marker) SubjectName() string {
// Subject returns the matching subject or nil.
func (m *Marker) Subject() (subj *Subject) {
if m.subject != nil {
if m.SubjectUID == m.subject.SubjectUID {
if m.SubjUID == m.subject.SubjUID {
return m.subject
}
}
// Create subject?
if m.SubjectSrc != SrcAuto && m.MarkerName != "" && m.SubjectUID == "" {
if subj = NewSubject(m.MarkerName, SubjectPerson, m.SubjectSrc); subj == nil {
if m.SubjSrc != SrcAuto && m.MarkerName != "" && m.SubjUID == "" {
if subj = NewSubject(m.MarkerName, SubjPerson, m.SubjSrc); subj == nil {
return nil
} else if subj = FirstOrCreateSubject(subj); subj == nil {
log.Debugf("marker: invalid subject %s", txt.Quote(m.MarkerName))
return nil
} else {
m.subject = subj
m.SubjectUID = subj.SubjectUID
m.SubjUID = subj.SubjUID
}
return m.subject
}
m.subject = FindSubject(m.SubjectUID)
m.subject = FindSubject(m.SubjUID)
return m.subject
}
@ -384,7 +384,7 @@ func (m *Marker) ClearSubject(src string) error {
}
// Update index & resolve collisions.
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "SubjectSrc": src}); err != nil {
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjUID": "", "SubjSrc": src}); err != nil {
return err
} else if m.face == nil {
m.subject = nil
@ -411,14 +411,14 @@ func (m *Marker) Face() (f *Face) {
}
// Add face if size
if m.SubjectSrc != SrcAuto && m.FaceID == "" {
if m.SubjSrc != SrcAuto && m.FaceID == "" {
if m.Size < face.ClusterMinSize || m.Score < face.ClusterMinScore {
log.Debugf("faces: skipped adding face for low-quality marker %s, size %d, score %d", m.MarkerUID, m.Size, m.Score)
return nil
} else if emb := m.Embeddings(); len(emb) == 0 {
log.Warnf("marker: %s has no embeddings", m.MarkerUID)
return nil
} else if f = NewFace(m.SubjectUID, m.SubjectSrc, emb); f == nil {
} else if f = NewFace(m.SubjUID, m.SubjSrc, emb); f == nil {
log.Warnf("marker: failed adding face for id %s", m.MarkerUID)
return nil
} else if f = FirstOrCreateFace(f); f == nil {
@ -452,9 +452,9 @@ func (m *Marker) ClearFace() (updated bool, err error) {
m.MatchedAt = TimePointer()
// Remove subject if set automatically.
if m.SubjectSrc == SrcAuto {
m.SubjectUID = ""
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "MatchedAt": m.MatchedAt})
if m.SubjSrc == SrcAuto {
m.SubjUID = ""
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjUID": "", "MatchedAt": m.MatchedAt})
} else {
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "MatchedAt": m.MatchedAt})
}

View file

@ -27,7 +27,7 @@ var MarkerFixtures = MarkerMap{
"1000003-1": Marker{ //Photo04
MarkerUID: "mqu0xs11qekk9jx8",
FileUID: "ft2es39w45bnlqdw",
SubjectUID: "jqu0xs11qekk9jx8",
SubjUID: "jqu0xs11qekk9jx8",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
X: 0.308333,
@ -40,7 +40,7 @@ var MarkerFixtures = MarkerMap{
"1000003-2": Marker{ //Photo04
MarkerUID: "mt9k3pw1wowuy3c3",
FileUID: "ft2es39w45bnlqdw",
SubjectUID: "lt9k3pw1wowuy3c3",
SubjUID: "lt9k3pw1wowuy3c3",
FaceID: "LRG2HJBDZE66LYG7Q5SRFXO2MDTOES52",
MarkerName: "Unknown",
MarkerSrc: SrcImage,
@ -55,7 +55,7 @@ var MarkerFixtures = MarkerMap{
"1000003-3": Marker{ //Photo04
MarkerUID: "mt9k3pw1wowuy111",
FileUID: "ft2es39w45bnlqdw",
SubjectUID: "",
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
MarkerName: "Center",
@ -69,7 +69,7 @@ var MarkerFixtures = MarkerMap{
"1000003-4": Marker{ //Photo04
MarkerUID: "mt9k3pw1wowuy222",
FileUID: "ft2es39w45bnlqdw",
SubjectUID: "",
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "Jens Mander",
@ -86,8 +86,8 @@ var MarkerFixtures = MarkerMap{
MarkerUID: "mt9k3pw1wowuy333",
FileUID: "ft2es39w45bnlqdw",
FaceID: FaceFixtures.Get("unknown").ID,
SubjectUID: "",
SubjectSrc: SrcAuto,
SubjUID: "",
SubjSrc: SrcAuto,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "Corn McCornface",
@ -105,8 +105,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft2es39w45bnlqdw",
FaceID: FaceFixtures.Get("john-doe").ID,
FaceDist: 0.2,
SubjectSrc: SrcAuto,
SubjectUID: "",
SubjSrc: SrcAuto,
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -124,8 +124,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft2es49qhhinlple",
FaceID: FaceFixtures.Get("fa-gr").ID,
FaceDist: 0.5,
SubjectSrc: "",
SubjectUID: "",
SubjSrc: "",
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -143,8 +143,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft2es49qhhinlple",
FaceID: FaceFixtures.Get("fa-gr").ID,
FaceDist: 0.6,
SubjectSrc: SrcAuto,
SubjectUID: "",
SubjSrc: SrcAuto,
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -162,8 +162,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft2es49w15bnlqdw",
FaceID: FaceFixtures.Get("fa-gr").ID,
FaceDist: 0.6,
SubjectSrc: SrcAuto,
SubjectUID: "",
SubjSrc: SrcAuto,
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -181,8 +181,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft8es39w45bnlqdw",
FaceID: FaceFixtures.Get("fa-gr").ID,
FaceDist: 0.6,
SubjectSrc: SrcAuto,
SubjectUID: "",
SubjSrc: SrcAuto,
SubjUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -201,8 +201,8 @@ var MarkerFixtures = MarkerMap{
FaceID: FaceFixtures.Get("actress-1").ID,
CropArea: "045038063041",
FaceDist: 0.26852392873736236,
SubjectSrc: SrcManual,
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
SubjSrc: SrcManual,
SubjUID: SubjectFixtures.Get("actress-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "Actress A",
@ -221,8 +221,8 @@ var MarkerFixtures = MarkerMap{
FaceID: FaceFixtures.Get("actress-1").ID,
CropArea: "046045043065",
FaceDist: 0.4507357278575355,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actress-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -241,8 +241,8 @@ var MarkerFixtures = MarkerMap{
FaceID: FaceFixtures.Get("actress-1").ID,
CropArea: "05403304060446",
FaceDist: 0.5099754448545762,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actress-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -260,8 +260,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft3es39w45bnlqdw",
FaceID: FaceFixtures.Get("actor-1").ID,
FaceDist: 0.5223304453393212,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actor-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actor-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -279,8 +279,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft2es39q45bnlqd0",
FaceID: FaceFixtures.Get("actor-1").ID,
FaceDist: 0.5088545446490167,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actor-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actor-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -298,8 +298,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "fikjs39w45bnlqdw",
FaceID: FaceFixtures.Get("actor-1").ID,
FaceDist: 0.3139983399779298,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actor-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actor-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",
@ -317,8 +317,8 @@ var MarkerFixtures = MarkerMap{
FileUID: "ft8es39w45bnlqdw",
FaceID: FaceFixtures.Get("actor-1").ID,
FaceDist: 0.3139983399779298,
SubjectSrc: "",
SubjectUID: SubjectFixtures.Get("actor-1").SubjectUID,
SubjSrc: "",
SubjUID: SubjectFixtures.Get("actor-1").SubjUID,
MarkerSrc: SrcImage,
MarkerType: MarkerFace,
MarkerName: "",

View file

@ -13,48 +13,48 @@ func (m *Marker) MarshalJSON() ([]byte, error) {
if subj = m.Subject(); subj == nil {
name = m.MarkerName
} else {
name = subj.SubjectName
name = subj.SubjName
}
return json.Marshal(&struct {
UID string
FileUID string
FileHash string
CropArea string
Type string
Src string
Name string
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"`
CreatedAt time.Time
UID string
FileUID string
FileHash string
CropArea string
Type string
Src string
Name string
Invalid bool
Review bool
FaceID string
SubjUID string
SubjSrc string
X float32
Y float32
W float32 `json:",omitempty"`
H float32 `json:",omitempty"`
Size int `json:",omitempty"`
Score int `json:",omitempty"`
CreatedAt time.Time
}{
UID: m.MarkerUID,
FileUID: m.FileUID,
FileHash: m.FileHash,
CropArea: m.CropArea,
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,
CreatedAt: m.CreatedAt,
UID: m.MarkerUID,
FileUID: m.FileUID,
FileHash: m.FileHash,
CropArea: m.CropArea,
Type: m.MarkerType,
Src: m.MarkerSrc,
Name: name,
Invalid: m.MarkerInvalid,
Review: m.MarkerReview,
FaceID: m.FaceID,
SubjUID: m.SubjUID,
SubjSrc: m.SubjSrc,
X: m.X,
Y: m.Y,
W: m.W,
H: m.H,
Size: m.Size,
Score: m.Score,
CreatedAt: m.CreatedAt,
})
}

View file

@ -27,7 +27,7 @@ func TestNewMarker(t *testing.T) {
assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID)
assert.Equal(t, "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818", m.FileHash)
assert.Equal(t, "1340ce163163", m.CropArea)
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID)
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjUID)
assert.Equal(t, SrcImage, m.MarkerSrc)
assert.Equal(t, MarkerLabel, m.MarkerType)
}
@ -38,16 +38,16 @@ func TestMarker_SaveForm(t *testing.T) {
m2 := MarkerFixtures.Get("fa-gr-2")
m3 := MarkerFixtures.Get("fa-gr-3")
assert.Empty(t, m.SubjectUID)
assert.Empty(t, m2.SubjectUID)
assert.Empty(t, m3.SubjectUID)
assert.Empty(t, m.SubjUID)
assert.Empty(t, m2.SubjUID)
assert.Empty(t, m3.SubjUID)
m.MarkerInvalid = true
m.Score = 50
//set new name
f := form.Marker{SubjectSrc: SrcManual, MarkerName: "Jane Doe", MarkerInvalid: false}
f := form.Marker{SubjSrc: SrcManual, MarkerName: "Jane Doe", MarkerInvalid: false}
err := m.SaveForm(f)
@ -55,20 +55,20 @@ func TestMarker_SaveForm(t *testing.T) {
t.Fatal(err)
}
assert.NotEmpty(t, m.SubjectUID)
assert.NotEmpty(t, m.SubjUID)
if s := m.Subject(); s != nil {
assert.Equal(t, "Jane Doe", s.SubjectName)
assert.Equal(t, "Jane Doe", s.SubjName)
}
if m := FindMarker("mt9k3pw1wowuy777"); m != nil {
assert.Equal(t, "Jane Doe", m.Subject().SubjectName)
assert.Equal(t, "Jane Doe", m.Subject().SubjName)
}
if m := FindMarker("mt9k3pw1wowuy888"); m != nil {
assert.Equal(t, "Jane Doe", m.Subject().SubjectName)
assert.Equal(t, "Jane Doe", m.Subject().SubjName)
}
// Rename subject.
f3 := form.Marker{SubjectSrc: SrcManual, MarkerName: "Franzilein", MarkerInvalid: false}
f3 := form.Marker{SubjSrc: SrcManual, MarkerName: "Franzilein", MarkerInvalid: false}
if m := FindMarker("mt9k3pw1wowuy777"); m == nil {
t.Fatal("result is nil")
@ -77,13 +77,13 @@ func TestMarker_SaveForm(t *testing.T) {
}
if m := FindMarker("mt9k3pw1wowuy666"); m != nil {
assert.Equal(t, "Franzilein", m.Subject().SubjectName)
assert.Equal(t, "Franzilein", m.Subject().SubjName)
}
if m := FindMarker("mt9k3pw1wowuy777"); m != nil {
assert.Equal(t, "Franzilein", m.Subject().SubjectName)
assert.Equal(t, "Franzilein", m.Subject().SubjName)
}
if m := FindMarker("mt9k3pw1wowuy888"); m != nil {
assert.Equal(t, "Franzilein", m.Subject().SubjectName)
assert.Equal(t, "Franzilein", m.Subject().SubjName)
}
})
}
@ -93,7 +93,7 @@ func TestUpdateOrCreateMarker(t *testing.T) {
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)
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjUID)
assert.Equal(t, SrcImage, m.MarkerSrc)
assert.Equal(t, MarkerLabel, m.MarkerType)
@ -227,10 +227,10 @@ func TestMarker_ClearSubject(t *testing.T) {
m3 := MarkerFixtures.Get("actor-a-2") // id 16
m4 := MarkerFixtures.Get("actor-a-1") // id 15
assert.Equal(t, "jqy1y111h1njaaad", m.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaad", m2.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaad", m3.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaad", m4.SubjectUID)
assert.Equal(t, "jqy1y111h1njaaad", m.SubjUID)
assert.Equal(t, "jqy1y111h1njaaad", m2.SubjUID)
assert.Equal(t, "jqy1y111h1njaaad", m3.SubjUID)
assert.Equal(t, "jqy1y111h1njaaad", m4.SubjUID)
assert.NotNil(t, m.Face())
assert.NotNil(t, m2.Face())
assert.NotNil(t, m3.Face())
@ -260,10 +260,10 @@ func TestMarker_ClearSubject(t *testing.T) {
assert.NotNil(t, FindMarker("mt9k3pw1wowu1002"))
assert.NotNil(t, FindFace("PI6A2XGOTUXEFI7CBF4KCI5I2I3JEJHS"))
assert.Empty(t, m.SubjectUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1004").SubjectUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1003").SubjectUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1002").SubjectUID)
assert.Empty(t, m.SubjUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1004").SubjUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1003").SubjUID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1002").SubjUID)
assert.Empty(t, m.FaceID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1004").FaceID)
assert.Equal(t, "", FindMarker("mt9k3pw1wowu1003").FaceID)
@ -385,43 +385,43 @@ func TestMarker_HasFace(t *testing.T) {
}
func TestMarker_Subject(t *testing.T) {
t.Run("EmptySubjectUID", func(t *testing.T) {
m := Marker{SubjectUID: "", subject: &Subject{SubjectUID: "", SubjectName: "Test Subject"}}
t.Run("EmptySubjUID", func(t *testing.T) {
m := Marker{SubjUID: "", subject: &Subject{SubjUID: "", SubjName: "Test Subject"}}
if s := m.Subject(); s == nil {
t.Fatal("return value must not be nil")
} else {
assert.Equal(t, "Test Subject", s.SubjectName)
assert.Equal(t, "", m.SubjectUID)
assert.Equal(t, "", s.SubjectUID)
assert.Equal(t, "Test Subject", s.SubjName)
assert.Equal(t, "", m.SubjUID)
assert.Equal(t, "", s.SubjUID)
}
})
t.Run("ConflictingSubjectUID", func(t *testing.T) {
m := Marker{SubjectUID: "", subject: &Subject{SubjectUID: "xyz", SubjectName: "Test Subject"}}
t.Run("ConflictingSubjUID", func(t *testing.T) {
m := Marker{SubjUID: "", subject: &Subject{SubjUID: "xyz", SubjName: "Test Subject"}}
if s := m.Subject(); s != nil {
t.Fatal("return value must be nil")
}
})
t.Run("SubjectSrcAuto", func(t *testing.T) {
m := Marker{SubjectSrc: SrcAuto, SubjectUID: "", MarkerName: "Hans Mayer"}
t.Run("SubjSrcAuto", func(t *testing.T) {
m := Marker{SubjSrc: SrcAuto, SubjUID: "", MarkerName: "Hans Mayer"}
if s := m.Subject(); s != nil {
t.Fatal("return value must be nil")
} else {
assert.Equal(t, "Hans Mayer", m.MarkerName)
assert.Empty(t, m.SubjectUID)
assert.Equal(t, SrcAuto, m.SubjectSrc)
assert.Empty(t, m.SubjUID)
assert.Equal(t, SrcAuto, m.SubjSrc)
}
})
t.Run("SubjectSrcManual", func(t *testing.T) {
m := Marker{SubjectSrc: SrcManual, SubjectUID: "", MarkerName: "Hans Mayer"}
t.Run("SubjSrcManual", func(t *testing.T) {
m := Marker{SubjSrc: SrcManual, SubjUID: "", MarkerName: "Hans Mayer"}
if s := m.Subject(); s == nil {
t.Fatal("return value must not be nil")
} else {
assert.Equal(t, "Hans Mayer", s.SubjectName)
assert.NotEmpty(t, s.SubjectUID)
assert.Equal(t, "Hans Mayer", s.SubjName)
assert.NotEmpty(t, s.SubjUID)
}
})
}
@ -453,11 +453,11 @@ func TestMarker_GetFace(t *testing.T) {
if f := m.Face(); f == nil {
t.Fatal("return value must not be nil")
} else {
assert.Equal(t, "jqy3y652h8njw0sx", f.SubjectUID)
assert.Equal(t, "jqy3y652h8njw0sx", f.SubjUID)
}
})
t.Run("low quality marker", func(t *testing.T) {
m := Marker{FaceID: "", SubjectSrc: SrcManual, Size: 130}
m := Marker{FaceID: "", SubjSrc: SrcManual, Size: 130}
assert.Nil(t, m.Face())
})
@ -465,7 +465,7 @@ func TestMarker_GetFace(t *testing.T) {
m := Marker{
FaceID: "",
EmbeddingsJSON: MarkerFixtures.Get("actress-a-1").EmbeddingsJSON,
SubjectSrc: SrcManual,
SubjSrc: SrcManual,
Size: 160,
Score: 40,
}
@ -499,13 +499,13 @@ func TestMarker_SetFace(t *testing.T) {
assert.Equal(t, "", m.FaceID)
})
t.Run("skip same face", func(t *testing.T) {
m := Marker{MarkerType: MarkerFace, SubjectUID: "jqu0xs11qekk9jx8", FaceID: "99876uyt"}
updated, _ := m.SetFace(&Face{ID: "99876uyt", SubjectUID: "jqu0xs11qekk9jx8"}, -1)
m := Marker{MarkerType: MarkerFace, SubjUID: "jqu0xs11qekk9jx8", FaceID: "99876uyt"}
updated, _ := m.SetFace(&Face{ID: "99876uyt", SubjUID: "jqu0xs11qekk9jx8"}, -1)
assert.False(t, updated)
assert.Equal(t, "99876uyt", m.FaceID)
})
t.Run("set new face", func(t *testing.T) {
m := Marker{MarkerUID: "mqyz9x61edicxf8j", MarkerType: MarkerFace, SubjectUID: "", FaceID: ""}
m := Marker{MarkerUID: "mqyz9x61edicxf8j", MarkerType: MarkerFace, SubjUID: "", FaceID: ""}
updated, _ := m.SetFace(FaceFixtures.Pointer("john-doe"), -1)
assert.True(t, updated)

View file

@ -65,7 +65,7 @@ func UpdatePhotoCounts() (err error) {
if err = Db().Table(Subject{}.TableName()).
UpdateColumn("file_count", gorm.Expr("(SELECT COUNT(*) FROM files f "+
fmt.Sprintf(
"JOIN %s m ON f.file_uid = m.file_uid AND m.subject_uid = %s.subject_uid ",
"JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid ",
Marker{}.TableName(),
Subject{}.TableName())+
" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL)")).Error; err != nil {

View file

@ -20,19 +20,19 @@ type Subjects []Subject
// Subject represents a named photo subject, typically a person.
type Subject struct {
SubjectUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
SubjUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
Thumb string `gorm:"type:VARBINARY(128);index;default:''" json:"Thumb,omitempty" yaml:"Thumb,omitempty"`
ThumbSrc string `gorm:"type:VARBINARY(8);default:''" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
SubjectType string `gorm:"type:VARBINARY(8);default:''" json:"Type,omitempty" yaml:"Type,omitempty"`
SubjectSrc string `gorm:"type:VARBINARY(8);default:''" json:"Src,omitempty" yaml:"Src,omitempty"`
SubjectSlug string `gorm:"type:VARBINARY(255);index;default:''" json:"Slug" yaml:"-"`
SubjectName string `gorm:"type:VARCHAR(255);unique_index;default:''" json:"Name" yaml:"Name"`
SubjectAlias string `gorm:"type:VARCHAR(255);default:''" json:"Alias" yaml:"Alias"`
SubjectBio string `gorm:"type:TEXT;default:''" json:"Bio" yaml:"Bio,omitempty"`
SubjectNotes string `gorm:"type:TEXT;default:''" json:"Notes,omitempty" yaml:"Notes,omitempty"`
Favorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"`
Private bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"`
Excluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"`
SubjType string `gorm:"type:VARBINARY(8);default:''" json:"Type,omitempty" yaml:"Type,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);default:''" json:"Src,omitempty" yaml:"Src,omitempty"`
SubjSlug string `gorm:"type:VARBINARY(255);index;default:''" json:"Slug" yaml:"-"`
SubjName string `gorm:"type:VARCHAR(255);unique_index;default:''" json:"Name" yaml:"Name"`
SubjAlias string `gorm:"type:VARCHAR(255);default:''" json:"Alias" yaml:"Alias"`
SubjBio string `gorm:"type:TEXT;default:''" json:"Bio" yaml:"Bio,omitempty"`
SubjNotes string `gorm:"type:TEXT;default:''" json:"Notes,omitempty" yaml:"Notes,omitempty"`
SubjFavorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"`
SubjPrivate bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"`
SubjExcluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"`
FileCount int `gorm:"default:0" json:"Files" yaml:"-"`
MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
@ -42,38 +42,38 @@ type Subject struct {
// TableName returns the entity database table name.
func (Subject) TableName() string {
return "subjects_dev8"
return "subjects_dev9"
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Subject) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.SubjectUID, 'j') {
if rnd.IsUID(m.SubjUID, 'j') {
return nil
}
return scope.SetColumn("SubjectUID", rnd.PPID('j'))
return scope.SetColumn("SubjUID", rnd.PPID('j'))
}
// NewSubject returns a new entity.
func NewSubject(name, subjectType, subjectSrc string) *Subject {
if subjectType == "" {
subjectType = SubjectPerson
func NewSubject(name, subjType, subjSrc string) *Subject {
if subjType == "" {
subjType = SubjPerson
}
subjectName := txt.Title(txt.Clip(name, txt.ClipDefault))
subjectSlug := slug.Make(txt.Clip(name, txt.ClipSlug))
subjName := txt.Title(txt.Clip(name, txt.ClipDefault))
subjSlug := slug.Make(txt.Clip(name, txt.ClipSlug))
// Name is required.
if subjectName == "" || subjectSlug == "" {
if subjName == "" || subjSlug == "" {
return nil
}
result := &Subject{
SubjectSlug: subjectSlug,
SubjectName: subjectName,
SubjectType: subjectType,
SubjectSrc: subjectSrc,
FileCount: 1,
SubjSlug: subjSlug,
SubjName: subjName,
SubjType: subjType,
SubjSrc: subjSrc,
FileCount: 1,
}
return result
@ -101,10 +101,12 @@ func (m *Subject) Delete() error {
return nil
}
log.Infof("subject: deleting %s %s", m.SubjectType, txt.Quote(m.SubjectName))
log.Infof("subject: deleting %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesDeleted("subjects", []string{m.SubjUID})
if m.IsPerson() {
event.EntitiesDeleted("people", []string{m.SubjectUID})
event.EntitiesDeleted("people", []string{m.SubjUID})
event.Publish("count.people", event.Data{
"count": -1,
})
@ -123,11 +125,12 @@ func (m *Subject) Restore() error {
if m.Deleted() {
m.DeletedAt = nil
log.Infof("subject: restoring %s %s", m.SubjectType, txt.Quote(m.SubjectName))
log.Infof("subject: restoring %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{
"count": 1,
})
@ -153,14 +156,16 @@ func (m *Subject) Updates(values interface{}) error {
func FirstOrCreateSubject(m *Subject) *Subject {
if m == nil {
return nil
} else if m.SubjectName == "" {
} else if m.SubjName == "" {
return nil
}
if found := FindSubjectByName(m.SubjectName); found != nil {
if found := FindSubjectByName(m.SubjName); found != nil {
return found
} else if createErr := m.Create(); createErr == nil {
log.Infof("subject: added %s %s", m.SubjectType, txt.Quote(m.SubjectName))
log.Infof("subject: added %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesCreated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()})
@ -170,10 +175,10 @@ func FirstOrCreateSubject(m *Subject) *Subject {
}
return m
} else if found = FindSubjectByName(m.SubjectName); found != nil {
} else if found = FindSubjectByName(m.SubjName); found != nil {
return found
} else {
log.Errorf("subject: %s while creating %s", createErr, txt.Quote(m.SubjectName))
log.Errorf("subject: %s while creating %s", createErr, txt.Quote(m.SubjName))
}
return nil
@ -187,7 +192,7 @@ func FindSubject(s string) *Subject {
result := Subject{}
db := Db().Where("subject_uid = ?", s)
db := Db().Where("subj_uid = ?", s)
if err := db.First(&result).Error; err != nil {
return nil
@ -205,7 +210,7 @@ func FindSubjectByName(s string) *Subject {
result := Subject{}
// Search database.
db := UnscopedDb().Where("subject_name LIKE ?", s).First(&result)
db := UnscopedDb().Where("subj_name LIKE ?", s).First(&result)
if err := db.First(&result).Error; err != nil {
return nil
@ -213,9 +218,9 @@ func FindSubjectByName(s string) *Subject {
// Restore if currently deleted.
if err := result.Restore(); err != nil {
log.Errorf("subject: %s could not be restored", result.SubjectUID)
log.Errorf("subject: %s could not be restored", result.SubjUID)
} else {
log.Debugf("subject: %s restored", result.SubjectUID)
log.Debugf("subject: %s restored", result.SubjUID)
}
return &result
@ -223,7 +228,7 @@ func FindSubjectByName(s string) *Subject {
// IsPerson tests if the subject is a person.
func (m *Subject) IsPerson() bool {
return m.SubjectType == SubjectPerson
return m.SubjType == SubjPerson
}
// Person creates and returns a Person based on this subject.
@ -239,8 +244,8 @@ func (m *Subject) SetName(name string) error {
return fmt.Errorf("subject: name must not be empty")
}
m.SubjectName = txt.Title(newName)
m.SubjectSlug = slug.Make(txt.Clip(name, txt.ClipSlug))
m.SubjName = txt.Title(newName)
m.SubjSlug = slug.Make(txt.Clip(name, txt.ClipSlug))
return nil
}
@ -249,15 +254,17 @@ func (m *Subject) SetName(name string) error {
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))
} else if err := m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err == nil {
log.Infof("subject: renamed %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesUpdated("subjects", []*Subject{m})
if m.IsPerson() {
event.EntitiesUpdated("people", []*Person{m.Person()})
}
return m, m.UpdateMarkerNames()
} else if existing := FindSubjectByName(m.SubjectName); existing == nil {
} else if existing := FindSubjectByName(m.SubjName); existing == nil {
return m, err
} else {
return existing, m.MergeWith(existing)
@ -266,16 +273,16 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
// UpdateMarkerNames updates related marker names.
func (m *Subject) UpdateMarkerNames() error {
if m.SubjectName == "" {
if m.SubjName == "" {
return fmt.Errorf("subject name is empty")
} else if m.SubjectUID == "" {
} else if m.SubjUID == "" {
return fmt.Errorf("subject uid is empty")
}
if err := Db().Model(&Marker{}).
Where("subject_uid = ? AND subject_src <> ?", m.SubjectUID, SrcAuto).
Where("marker_name <> ?", m.SubjectName).
Update(Values{"MarkerName": m.SubjectName}).Error; err != nil {
Where("subj_uid = ? AND subj_src <> ?", m.SubjUID, SrcAuto).
Where("marker_name <> ?", m.SubjName).
Update(Values{"MarkerName": m.SubjName}).Error; err != nil {
return err
}
@ -284,33 +291,33 @@ func (m *Subject) UpdateMarkerNames() error {
// RefreshPhotos flags related photos for metadata maintenance.
func (m *Subject) RefreshPhotos() error {
if m.SubjectUID == "" {
if m.SubjUID == "" {
return fmt.Errorf("empty subject uid")
}
return UnscopedDb().Exec(`UPDATE photos SET checked_at = NULL WHERE id IN
(SELECT f.photo_id FROM files f JOIN ? m ON m.file_uid = f.file_uid WHERE m.subject_uid = ? GROUP BY f.photo_id)`,
gorm.Expr(Marker{}.TableName()), m.SubjectUID).Error
(SELECT f.photo_id FROM files f JOIN ? m ON m.file_uid = f.file_uid WHERE m.subj_uid = ? GROUP BY f.photo_id)`,
gorm.Expr(Marker{}.TableName()), m.SubjUID).Error
}
// MergeWith merges this subject with another subject and then deletes it.
func (m *Subject) MergeWith(other *Subject) error {
if other == nil {
return fmt.Errorf("other subject is nil")
} else if other.SubjectUID == "" {
} else if other.SubjUID == "" {
return fmt.Errorf("other subject's uid is empty")
} else if m.SubjectUID == "" {
} else if m.SubjUID == "" {
return fmt.Errorf("subject uid is empty")
}
// Update markers and faces with new SubjectUID.
// Update markers and faces with new SubjUID.
if err := Db().Model(&Marker{}).
Where("subject_uid = ?", m.SubjectUID).
Update(Values{"SubjectUID": other.SubjectUID}).Error; err != nil {
Where("subj_uid = ?", m.SubjUID).
Update(Values{"SubjUID": other.SubjUID}).Error; err != nil {
return err
} else if err := Db().Model(&Face{}).
Where("subject_uid = ?", m.SubjectUID).
Update(Values{"SubjectUID": other.SubjectUID}).Error; err != nil {
Where("subj_uid = ?", m.SubjUID).
Update(Values{"SubjUID": other.SubjUID}).Error; err != nil {
return err
} else if err := other.UpdateMarkerNames(); err != nil {
return err
@ -321,5 +328,5 @@ func (m *Subject) MergeWith(other *Subject) error {
// Links returns all share links for this entity.
func (m *Subject) Links() Links {
return FindLinks("", m.SubjectUID)
return FindLinks("", m.SubjUID)
}

View file

@ -20,16 +20,16 @@ func (m SubjectMap) Pointer(name string) *Subject {
var SubjectFixtures = SubjectMap{
"john-doe": Subject{
SubjectUID: "jqu0xs11qekk9jx8",
SubjectSlug: "john-doe",
SubjectName: "John Doe",
SubjectType: SubjectPerson,
SubjectSrc: SrcManual,
Favorite: true,
Private: false,
Excluded: false,
SubjectBio: "Subject Description",
SubjectNotes: "Short Note",
SubjUID: "jqu0xs11qekk9jx8",
SubjSlug: "john-doe",
SubjName: "John Doe",
SubjType: SubjPerson,
SubjSrc: SrcManual,
SubjFavorite: true,
SubjPrivate: false,
SubjExcluded: false,
SubjBio: "Subject Description",
SubjNotes: "Short Note",
MetadataJSON: []byte(""),
FileCount: 1,
CreatedAt: TimeStamp(),
@ -37,16 +37,16 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil,
},
"joe-biden": Subject{
SubjectUID: "jqy3y652h8njw0sx",
SubjectSlug: "joe-biden",
SubjectName: "Joe Biden",
SubjectType: SubjectPerson,
SubjectSrc: SrcMarker,
Favorite: false,
Private: false,
Excluded: false,
SubjectBio: "",
SubjectNotes: "",
SubjUID: "jqy3y652h8njw0sx",
SubjSlug: "joe-biden",
SubjName: "Joe Biden",
SubjType: SubjPerson,
SubjSrc: SrcMarker,
SubjFavorite: false,
SubjPrivate: false,
SubjExcluded: false,
SubjBio: "",
SubjNotes: "",
MetadataJSON: []byte(""),
FileCount: 1,
CreatedAt: TimeStamp(),
@ -54,17 +54,17 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil,
},
"dangling": Subject{
SubjectUID: "jqy1y111h1njaaaa",
SubjectSlug: "dangling-subject",
SubjectName: "Dangling Subject",
SubjectAlias: "Powell",
SubjectType: SubjectPerson,
SubjectSrc: SrcMarker,
Favorite: false,
Private: false,
Excluded: false,
SubjectBio: "",
SubjectNotes: "",
SubjUID: "jqy1y111h1njaaaa",
SubjSlug: "dangling-subject",
SubjName: "Dangling Subject",
SubjAlias: "Powell",
SubjType: SubjPerson,
SubjSrc: SrcMarker,
SubjFavorite: false,
SubjPrivate: false,
SubjExcluded: false,
SubjBio: "",
SubjNotes: "",
MetadataJSON: []byte(""),
FileCount: 0,
CreatedAt: TimeStamp(),
@ -72,16 +72,16 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil,
},
"jane-doe": Subject{
SubjectUID: "jqy1y111h1njaaab",
SubjectSlug: "jane-doe",
SubjectName: "Jane Doe",
SubjectType: SubjectPerson,
SubjectSrc: SrcMarker,
Favorite: false,
Private: false,
Excluded: false,
SubjectBio: "",
SubjectNotes: "",
SubjUID: "jqy1y111h1njaaab",
SubjSlug: "jane-doe",
SubjName: "Jane Doe",
SubjType: SubjPerson,
SubjSrc: SrcMarker,
SubjFavorite: false,
SubjPrivate: false,
SubjExcluded: false,
SubjBio: "",
SubjNotes: "",
MetadataJSON: []byte(""),
FileCount: 3,
CreatedAt: TimeStamp(),
@ -89,28 +89,28 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil,
},
"actress-1": Subject{
SubjectUID: "jqy1y111h1njaaac",
SubjectSlug: "actress-a",
SubjectName: "Actress A",
SubjectType: SubjectPerson,
SubjectSrc: SrcMarker,
Favorite: false,
Private: false,
SubjectNotes: "",
SubjUID: "jqy1y111h1njaaac",
SubjSlug: "actress-a",
SubjName: "Actress A",
SubjType: SubjPerson,
SubjSrc: SrcMarker,
SubjFavorite: false,
SubjPrivate: false,
SubjNotes: "",
MetadataJSON: []byte(""),
CreatedAt: TimeStamp(),
UpdatedAt: TimeStamp(),
DeletedAt: nil,
},
"actor-1": Subject{
SubjectUID: "jqy1y111h1njaaad",
SubjectSlug: "actor-a",
SubjectName: "Actor A",
SubjectType: SubjectPerson,
SubjectSrc: SrcMarker,
Favorite: false,
Private: false,
SubjectNotes: "",
SubjUID: "jqy1y111h1njaaad",
SubjSlug: "actor-a",
SubjName: "Actor A",
SubjType: SubjPerson,
SubjSrc: SrcMarker,
SubjFavorite: false,
SubjPrivate: false,
SubjNotes: "",
MetadataJSON: []byte(""),
CreatedAt: TimeStamp(),
UpdatedAt: TimeStamp(),

View file

@ -9,12 +9,12 @@ import (
func TestSubjectMap_Get(t *testing.T) {
t.Run("get existing subject", func(t *testing.T) {
r := SubjectFixtures.Get("joe-biden")
assert.Equal(t, "Joe Biden", r.SubjectName)
assert.Equal(t, "Joe Biden", r.SubjName)
assert.IsType(t, Subject{}, r)
})
t.Run("get not existing subject", func(t *testing.T) {
r := SubjectFixtures.Get("monstera")
assert.Equal(t, "", r.SubjectName)
assert.Equal(t, "", r.SubjName)
assert.IsType(t, Subject{}, r)
})
}
@ -22,12 +22,12 @@ func TestSubjectMap_Get(t *testing.T) {
func TestSubjectMap_Pointer(t *testing.T) {
t.Run("get existing subject", func(t *testing.T) {
r := SubjectFixtures.Pointer("joe-biden")
assert.Equal(t, "Joe Biden", r.SubjectName)
assert.Equal(t, "Joe Biden", r.SubjName)
assert.IsType(t, &Subject{}, r)
})
t.Run("get not existing subject", func(t *testing.T) {
r := SubjectFixtures.Pointer("monstera")
assert.Equal(t, "", r.SubjectName)
assert.Equal(t, "", r.SubjName)
assert.IsType(t, &Subject{}, r)
})
}

View file

@ -7,7 +7,7 @@ import (
)
const (
SubjectPerson = "person"
SubjPerson = "person"
)
// People represents a list of people.
@ -15,20 +15,20 @@ 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"`
SubjUID string `json:"UID"`
SubjName string `json:"Name"`
SubjAlias string `json:"Alias"`
SubjFavorite bool `json:"Favorite"`
Thumb string `json:"Thumb"`
}
// NewPerson returns a new entity.
func NewPerson(subj Subject) *Person {
result := &Person{
SubjectUID: subj.SubjectUID,
SubjectName: subj.SubjectName,
SubjectAlias: subj.SubjectAlias,
Favorite: subj.Favorite,
SubjUID: subj.SubjUID,
SubjName: subj.SubjName,
SubjAlias: subj.SubjAlias,
SubjFavorite: subj.SubjFavorite,
Thumb: subj.Thumb,
}
@ -44,10 +44,10 @@ func (m *Person) MarshalJSON() ([]byte, error) {
Favorite bool `json:",omitempty"`
Thumb string `json:",omitempty"`
}{
UID: m.SubjectUID,
Name: m.SubjectName,
Keywords: txt.NameKeywords(m.SubjectName, m.SubjectAlias),
Favorite: m.Favorite,
UID: m.SubjUID,
Name: m.SubjName,
Keywords: txt.NameKeywords(m.SubjName, m.SubjAlias),
Favorite: m.SubjFavorite,
Thumb: m.Thumb,
})
}

View file

@ -9,19 +9,19 @@ import (
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,
SubjUID: "jqytw12v8jjeu3e6",
SubjName: "William Henry Gates III",
SubjAlias: "Windows Guru",
SubjFavorite: 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, "jqytw12v8jjeu3e6", m.SubjUID)
assert.Equal(t, "William Henry Gates III", m.SubjName)
assert.Equal(t, "Windows Guru", m.SubjAlias)
assert.Equal(t, true, m.SubjFavorite)
assert.Equal(t, "622c7287967f2800e873fbc55f0328973056ce1d", m.Thumb)
if j, err := m.MarshalJSON(); err != nil {

View file

@ -14,16 +14,16 @@ func TestSubject_TableName(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)
assert.Equal(t, "jens-mander", m.SubjectSlug)
assert.Equal(t, "person", m.SubjectType)
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjName)
assert.Equal(t, "jens-mander", m.SubjSlug)
assert.Equal(t, "person", m.SubjType)
})
t.Run("subject Type empty", func(t *testing.T) {
m := NewSubject("Anna Mander", "", SrcAuto)
assert.Equal(t, "Anna Mander", m.SubjectName)
assert.Equal(t, "anna-mander", m.SubjectSlug)
assert.Equal(t, "person", m.SubjectType)
assert.Equal(t, "Anna Mander", m.SubjName)
assert.Equal(t, "anna-mander", m.SubjSlug)
assert.Equal(t, "person", m.SubjType)
})
t.Run("subject name empty", func(t *testing.T) {
m := NewSubject("", "", SrcAuto)
@ -33,44 +33,44 @@ func TestNewSubject(t *testing.T) {
func TestSubject_SetName(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjectPerson, SrcAuto)
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjectName)
assert.Equal(t, "jens-mander", m.SubjectSlug)
assert.Equal(t, "Jens Mander", m.SubjName)
assert.Equal(t, "jens-mander", m.SubjSlug)
if err := m.SetName("Foo McBar"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "Foo McBar", m.SubjectName)
assert.Equal(t, "foo-mcbar", m.SubjectSlug)
assert.Equal(t, "Foo McBar", m.SubjName)
assert.Equal(t, "foo-mcbar", m.SubjSlug)
})
t.Run("new name empty", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjectPerson, SrcAuto)
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
assert.Equal(t, "Jens Mander", m.SubjectName)
assert.Equal(t, "jens-mander", m.SubjectSlug)
assert.Equal(t, "Jens Mander", m.SubjName)
assert.Equal(t, "jens-mander", m.SubjSlug)
err := m.SetName("")
if err == nil {
t.Fatal(err)
}
assert.Equal(t, "subject: name must not be empty", err.Error())
assert.Equal(t, "Jens Mander", m.SubjectName)
assert.Equal(t, "Jens Mander", m.SubjName)
})
}
func TestFirstOrCreatePerson(t *testing.T) {
t.Run("not yet existing person", func(t *testing.T) {
m := NewSubject("Create Me", SubjectPerson, SrcAuto)
m := NewSubject("Create Me", SubjPerson, SrcAuto)
result := FirstOrCreateSubject(m)
if result == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, "Create Me", m.SubjectName)
assert.Equal(t, "create-me", m.SubjectSlug)
assert.Equal(t, "Create Me", m.SubjName)
assert.Equal(t, "create-me", m.SubjSlug)
})
t.Run("existing person", func(t *testing.T) {
m := SubjectFixtures.Pointer("john-doe")
@ -80,15 +80,15 @@ func TestFirstOrCreatePerson(t *testing.T) {
t.Fatal("result should not be nil")
}
assert.Equal(t, "John Doe", m.SubjectName)
assert.Equal(t, "john-doe", m.SubjectSlug)
assert.Equal(t, "Short Note", m.SubjectNotes)
assert.Equal(t, "John Doe", m.SubjName)
assert.Equal(t, "john-doe", m.SubjSlug)
assert.Equal(t, "Short Note", m.SubjNotes)
})
}
func TestSubject_Save(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Save Me", SubjectPerson, SrcAuto)
m := NewSubject("Save Me", SubjPerson, SrcAuto)
initialDate := m.UpdatedAt
err := m.Save()
@ -105,13 +105,13 @@ func TestSubject_Save(t *testing.T) {
func TestSubject_Delete(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Jens Mander", SubjectPerson, SrcAuto)
m := NewSubject("Jens Mander", SubjPerson, SrcAuto)
err := m.Save()
assert.False(t, m.Deleted())
var subj Subjects
if err := Db().Where("subject_name = ?", m.SubjectName).Find(&subj).Error; err != nil {
if err := Db().Where("subj_name = ?", m.SubjName).Find(&subj).Error; err != nil {
t.Fatal(err)
}
@ -122,7 +122,7 @@ func TestSubject_Delete(t *testing.T) {
t.Fatal(err)
}
if err := Db().Where("subject_name = ?", m.SubjectName).Find(&subj).Error; err != nil {
if err := Db().Where("subj_name = ?", m.SubjName).Find(&subj).Error; err != nil {
t.Fatal(err)
}
@ -134,7 +134,7 @@ func TestSubject_Restore(t *testing.T) {
t.Run("success", func(t *testing.T) {
var deleteTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
m := &Subject{DeletedAt: &deleteTime, SubjectName: "ToBeRestored"}
m := &Subject{DeletedAt: &deleteTime, SubjName: "ToBeRestored"}
err := m.Save()
if err != nil {
t.Fatal(err)
@ -148,7 +148,7 @@ func TestSubject_Restore(t *testing.T) {
assert.False(t, m.Deleted())
})
t.Run("subject not deleted", func(t *testing.T) {
m := &Subject{DeletedAt: nil, SubjectName: "NotDeleted1234"}
m := &Subject{DeletedAt: nil, SubjName: "NotDeleted1234"}
err := m.Restore()
if err != nil {
t.Fatal(err)
@ -159,18 +159,18 @@ func TestSubject_Restore(t *testing.T) {
func TestFindSubject(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Find Me", SubjectPerson, SrcAuto)
m := NewSubject("Find Me", SubjPerson, SrcAuto)
if err := m.Save(); err != nil {
t.Fatal(err)
}
if s := FindSubject(m.SubjectName); s != nil {
if s := FindSubject(m.SubjName); s != nil {
t.Fatal("result must be nil")
}
if s := FindSubject(m.SubjectUID); s != nil {
assert.Equal(t, "Find Me", s.SubjectName)
if s := FindSubject(m.SubjUID); s != nil {
assert.Equal(t, "Find Me", s.SubjName)
} else {
t.Fatal("result must not be nil")
}
@ -195,33 +195,33 @@ func TestSubject_Links(t *testing.T) {
func TestSubject_Update(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Update Me", SubjectPerson, SrcAuto)
m := NewSubject("Update Me", SubjPerson, SrcAuto)
if err := m.Save(); err != nil {
t.Fatal(err)
}
if err := m.Update("SubjectName", "Updated Name"); err != nil {
if err := m.Update("SubjName", "Updated Name"); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "Updated Name", m.SubjectName)
assert.Equal(t, "Updated Name", m.SubjName)
}
})
}
func TestSubject_Updates(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Update Me", SubjectPerson, SrcAuto)
m := NewSubject("Update Me", SubjPerson, SrcAuto)
if err := m.Save(); err != nil {
t.Fatal(err)
}
if err := m.Updates(Subject{SubjectName: "UpdatedName", SubjectType: "UpdatedType"}); err != nil {
if err := m.Updates(Subject{SubjName: "UpdatedName", SubjType: "UpdatedType"}); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "UpdatedName", m.SubjectName)
assert.Equal(t, "UpdatedType", m.SubjectType)
assert.Equal(t, "UpdatedName", m.SubjName)
assert.Equal(t, "UpdatedType", m.SubjType)
}
})
@ -229,45 +229,45 @@ func TestSubject_Updates(t *testing.T) {
func TestSubject_UpdateName(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := NewSubject("Test Person", SubjectPerson, SrcAuto)
m := NewSubject("Test Person", SubjPerson, SrcAuto)
if err := m.Save(); err != nil {
t.Fatal(err)
}
assert.Equal(t, "Test Person", m.SubjectName)
assert.Equal(t, "test-person", m.SubjectSlug)
assert.Equal(t, "Test Person", m.SubjName)
assert.Equal(t, "test-person", m.SubjSlug)
if s, err := m.UpdateName("New New"); err != nil {
t.Fatal(err)
} else if s == nil {
t.Fatal("subject is nil")
} else {
assert.Equal(t, "New New", m.SubjectName)
assert.Equal(t, "new-new", m.SubjectSlug)
assert.Equal(t, "New New", s.SubjectName)
assert.Equal(t, "new-new", s.SubjectSlug)
assert.Equal(t, "New New", m.SubjName)
assert.Equal(t, "new-new", m.SubjSlug)
assert.Equal(t, "New New", s.SubjName)
assert.Equal(t, "new-new", s.SubjSlug)
}
})
t.Run("empty name", func(t *testing.T) {
m := NewSubject("Test Person2", SubjectPerson, SrcAuto)
m := NewSubject("Test Person2", SubjPerson, SrcAuto)
if err := m.Save(); err != nil {
t.Fatal(err)
}
assert.Equal(t, "Test Person2", m.SubjectName)
assert.Equal(t, "test-person2", m.SubjectSlug)
assert.Equal(t, "Test Person2", m.SubjName)
assert.Equal(t, "test-person2", m.SubjSlug)
if s, err := m.UpdateName(""); err == nil {
t.Error("error expected")
} else if s == nil {
t.Fatal("subject is nil")
} else {
assert.Equal(t, "Test Person2", m.SubjectName)
assert.Equal(t, "test-person2", m.SubjectSlug)
assert.Equal(t, "Test Person2", s.SubjectName)
assert.Equal(t, "test-person2", s.SubjectSlug)
assert.Equal(t, "Test Person2", m.SubjName)
assert.Equal(t, "test-person2", m.SubjSlug)
assert.Equal(t, "Test Person2", s.SubjName)
assert.Equal(t, "test-person2", s.SubjSlug)
}
})
}

View file

@ -4,9 +4,9 @@ import "github.com/ulule/deepcopier"
// Marker represents an image marker edit form.
type Marker struct {
SubjectSrc string `json:"SubjectSrc"`
SubjSrc string `json:"SubjSrc"`
MarkerName string `json:"Name"`
Review bool `json:"Review"`
MarkerReview bool `json:"MarkerReview"`
MarkerInvalid bool `json:"Invalid"`
}

View file

@ -9,14 +9,14 @@ import (
func TestNewMarker(t *testing.T) {
t.Run("success", func(t *testing.T) {
var m = struct {
SubjectSrc string
SubjSrc string
MarkerName string
Review bool
MarkerReview bool
MarkerInvalid bool
}{
SubjectSrc: "manual",
SubjSrc: "manual",
MarkerName: "Foo",
Review: true,
MarkerReview: true,
MarkerInvalid: true,
}
@ -26,9 +26,9 @@ func TestNewMarker(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "manual", f.SubjectSrc)
assert.Equal(t, "manual", f.SubjSrc)
assert.Equal(t, "Foo", f.MarkerName)
assert.Equal(t, true, f.Review)
assert.Equal(t, true, f.MarkerReview)
assert.Equal(t, true, f.MarkerInvalid)
})
}

View file

@ -3,11 +3,12 @@ package form
import "strings"
type Selection struct {
Files []string `json:"files"`
Photos []string `json:"photos"`
Albums []string `json:"albums"`
Labels []string `json:"labels"`
Places []string `json:"places"`
Files []string `json:"files"`
Photos []string `json:"photos"`
Albums []string `json:"albums"`
Labels []string `json:"labels"`
Places []string `json:"places"`
Subjects []string `json:"subjects"`
}
func (f Selection) Empty() bool {
@ -22,6 +23,8 @@ func (f Selection) Empty() bool {
return false
case len(f.Places) > 0:
return false
case len(f.Subjects) > 0:
return false
}
return true
@ -36,6 +39,7 @@ func (f Selection) All() []string {
all = append(all, f.Albums...)
all = append(all, f.Labels...)
all = append(all, f.Places...)
all = append(all, f.Subjects...)
return all
}

View file

@ -27,6 +27,11 @@ func TestSelection_Empty(t *testing.T) {
sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}, Files: []string{}, Places: []string{"foo", "bar"}}
assert.Equal(t, false, sel.Empty())
})
t.Run("not empty subjects", func(t *testing.T) {
sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}, Files: []string{}, Places: []string{}, Subjects: []string{"jqzkpo13j8ngpgv4", "jqzkq8j10hj39sxp"}}
assert.Equal(t, false, sel.Empty())
assert.Equal(t, []string{"jqzkpo13j8ngpgv4", "jqzkq8j10hj39sxp"}, sel.Subjects)
})
t.Run("empty", func(t *testing.T) {
sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}}
assert.Equal(t, true, sel.Empty())
@ -35,8 +40,8 @@ func TestSelection_Empty(t *testing.T) {
func TestSelection_All(t *testing.T) {
t.Run("success", func(t *testing.T) {
sel := Selection{Photos: []string{"p123", "p456"}, Albums: []string{"a123"}, Labels: []string{"l123", "l456", "l789"}, Files: []string{"f567", "f111"}, Places: []string{"p568"}}
assert.Equal(t, []string{"p123", "p456", "a123", "l123", "l456", "l789", "p568"}, sel.All())
sel := Selection{Photos: []string{"p123", "p456"}, Albums: []string{"a123"}, Labels: []string{"l123", "l456", "l789"}, Files: []string{"f567", "f111"}, Places: []string{"p568"}, Subjects: []string{"jqzkpo13j8ngpgv4"}}
assert.Equal(t, []string{"p123", "p456", "a123", "l123", "l456", "l789", "p568", "jqzkpo13j8ngpgv4"}, sel.All())
})
}

20
internal/form/subject.go Normal file
View file

@ -0,0 +1,20 @@
package form
import "github.com/ulule/deepcopier"
// Subject represents an image subject edit form.
type Subject struct {
SubjName string `json:"Name"`
SubjAlias string `json:"Alias"`
SubjBio string `json:"Bio"`
SubjNotes string `json:"Notes"`
SubjFavorite bool `json:"Favorite"`
SubjPrivate bool `json:"Private"`
SubjExcluded bool `json:"Excluded"`
}
func NewSubject(m interface{}) (f Subject, err error) {
err = deepcopier.Copy(m).To(&f)
return f, err
}

View file

@ -0,0 +1,37 @@
package form
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewSubject(t *testing.T) {
t.Run("success", func(t *testing.T) {
var m = struct {
SubjName string `json:"Name"`
SubjAlias string `json:"Alias"`
SubjBio string `json:"Bio"`
SubjNotes string `json:"Notes"`
SubjFavorite bool `json:"Favorite"`
SubjPrivate bool `json:"Private"`
SubjExcluded bool `json:"Excluded"`
}{
SubjName: "Foo",
SubjAlias: "bar",
SubjFavorite: true,
SubjExcluded: false,
}
f, err := NewSubject(m)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Foo", f.SubjName)
assert.Equal(t, "bar", f.SubjAlias)
assert.Equal(t, true, f.SubjFavorite)
assert.Equal(t, false, f.SubjExcluded)
})
}

View file

@ -60,6 +60,8 @@ const (
MsgCopyingFilesFrom
MsgLabelsDeleted
MsgLabelSaved
MsgSubjectSaved
MsgSubjectDeleted
MsgFilesUploadedIn
MsgSelectionApproved
MsgSelectionArchived
@ -132,6 +134,8 @@ var Messages = MessageMap{
MsgCopyingFilesFrom: gettext("Copying files from %s"),
MsgLabelsDeleted: gettext("Labels deleted"),
MsgLabelSaved: gettext("Label saved"),
MsgSubjectSaved: gettext("Subject saved"),
MsgSubjectDeleted: gettext("Subject deleted"),
MsgFilesUploadedIn: gettext("%d files uploaded in %d s"),
MsgSelectionApproved: gettext("Selection approved"),
MsgSelectionArchived: gettext("Selection archived"),

View file

@ -65,7 +65,7 @@ func (w *Faces) Audit(fix bool) (err error) {
for _, f2 := range faces {
if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched {
if f1.SubjectUID == f2.SubjectUID {
if f1.SubjUID == f2.SubjUID {
continue
}
@ -75,14 +75,14 @@ func (w *Faces) Audit(fix bool) (err error) {
log.Infof("face %s: conflict at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
if f1.SubjectUID != "" {
log.Infof("face %s: subject %s (%s %s)", f1.ID, txt.Quote(subj[f1.SubjectUID].SubjectName), f1.SubjectUID, entity.SrcString(f1.FaceSrc))
if f1.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f1.ID, txt.Quote(subj[f1.SubjUID].SubjName), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else {
log.Infof("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
}
if f2.SubjectUID != "" {
log.Infof("face %s: subject %s (%s %s)", f2.ID, txt.Quote(subj[f2.SubjectUID].SubjectName), f2.SubjectUID, entity.SrcString(f2.FaceSrc))
if f2.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f2.ID, txt.Quote(subj[f2.SubjUID].SubjName), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else {
log.Infof("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
}
@ -113,7 +113,7 @@ func (w *Faces) Audit(fix bool) (err error) {
log.Error(err)
} else {
for _, m := range markers {
log.Infof("marker %s: %s subject %s conflicts with face %s subject %s", m.MarkerUID, entity.SrcString(m.SubjectSrc), txt.Quote(subj[m.SubjectUID].SubjectName), m.FaceID, txt.Quote(subj[faceMap[m.FaceID].SubjectUID].SubjectName))
log.Infof("marker %s: %s subject %s conflicts with face %s subject %s", m.MarkerUID, entity.SrcString(m.SubjSrc), txt.Quote(subj[m.SubjUID].SubjName), m.FaceID, txt.Quote(subj[faceMap[m.FaceID].SubjUID].SubjName))
}
}

View file

@ -167,7 +167,7 @@ func (w *Faces) MatchFaces(faces entity.Faces, force bool, matchedBefore *time.T
result.Updated++
}
if marker.SubjectUID != "" {
if marker.SubjUID != "" {
result.Recognized++
} else {
result.Unknown++

View file

@ -37,7 +37,7 @@ func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
for j := 0; j <= n; j++ {
if len(merge) == 0 {
merge = entity.Faces{faces[j]}
} else if faces[j].SubjectUID != merge[len(merge)-1].SubjectUID || j == n {
} else if faces[j].SubjUID != merge[len(merge)-1].SubjUID || j == n {
if len(merge) < 2 {
// Nothing to merge.
} else if _, err := query.MergeFaces(merge); err != nil {
@ -48,7 +48,7 @@ func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
merge = nil
} else if ok, dist := merge[0].Match(entity.Embeddings{faces[j].Embedding()}); ok {
log.Debugf("faces: can merge %s with %s, subject %s, dist %f", merge[0].ID, faces[j].ID, merge[0].SubjectUID, dist)
log.Debugf("faces: can merge %s with %s, subject %s, dist %f", merge[0].ID, faces[j].ID, merge[0].SubjUID, dist)
merge = append(merge, faces[j])
} else if len(merge) == 1 {
merge = nil

View file

@ -69,7 +69,7 @@ func (w *Faces) Stats() (err error) {
min := -1.0
max := -1.0
if k, ok := dist[f1.SubjectUID]; ok {
if k, ok := dist[f1.SubjUID]; ok {
min = k[0]
max = k[1]
}
@ -81,7 +81,7 @@ func (w *Faces) Stats() (err error) {
f2 := faces[j]
if f1.SubjectUID != f2.SubjectUID {
if f1.SubjUID != f2.SubjUID {
continue
}
@ -99,7 +99,7 @@ func (w *Faces) Stats() (err error) {
}
if max > 0 {
dist[f1.SubjectUID] = []float64{min, max}
dist[f1.SubjUID] = []float64{min, max}
}
}

View file

@ -19,10 +19,10 @@ func Faces(knownOnly, unmatched bool) (result entity.Faces, err error) {
}
if knownOnly {
stmt = stmt.Where("subject_uid <> ''")
stmt = stmt.Where("subj_uid <> ''")
}
err = stmt.Order("subject_uid, samples DESC").Find(&result).Error
err = stmt.Order("subj_uid, samples DESC").Find(&result).Error
return result, err
}
@ -31,7 +31,7 @@ func Faces(knownOnly, unmatched bool) (result entity.Faces, err error) {
func ManuallyAddedFaces() (result entity.Faces, err error) {
err = Db().
Where("face_src = ?", entity.SrcManual).
Where("subject_uid <> ''").Order("subject_uid, samples DESC").
Where("subj_uid <> ''").Order("subj_uid, samples DESC").
Find(&result).Error
return result, err
@ -48,9 +48,9 @@ func MatchFaceMarkers() (affected int64, err error) {
for _, f := range faces {
if res := Db().Model(&entity.Marker{}).
Where("face_id = ?", f.ID).
Where("subject_src = ?", entity.SrcAuto).
Where("subject_uid <> ?", f.SubjectUID).
Updates(entity.Values{"SubjectUID": f.SubjectUID, "Review": false}); res.Error != nil {
Where("subj_src = ?", entity.SrcAuto).
Where("subj_uid <> ?", f.SubjUID).
Updates(entity.Values{"SubjUID": f.SubjUID, "MarkerReview": false}); res.Error != nil {
return affected, err
} else if res.RowsAffected > 0 {
affected += res.RowsAffected
@ -64,7 +64,7 @@ func MatchFaceMarkers() (affected int64, err error) {
func RemoveAnonymousFaceClusters() (removed int64, err error) {
res := UnscopedDb().Delete(
entity.Face{},
"face_src = ? AND subject_uid = ''", entity.SrcAuto)
"face_src = ? AND subj_uid = ''", entity.SrcAuto)
return res.RowsAffected, res.Error
}
@ -131,20 +131,20 @@ func MergeFaces(merge entity.Faces) (merged *entity.Face, err error) {
return merged, fmt.Errorf("faces: two or more clusters required for merging")
}
subjectUID := merge[0].SubjectUID
subjUID := merge[0].SubjUID
for i := 1; i < len(merge); i++ {
if merge[i].SubjectUID != subjectUID {
if merge[i].SubjUID != subjUID {
return merged, fmt.Errorf("faces: can't merge clusters with conflicting subjects %s <> %s",
txt.Quote(subjectUID), txt.Quote(merge[i].SubjectUID))
txt.Quote(subjUID), txt.Quote(merge[i].SubjUID))
}
}
// Find or create merged face cluster.
if merged = entity.NewFace(merge[0].SubjectUID, merge[0].FaceSrc, merge.Embeddings()); merged == nil {
return merged, fmt.Errorf("faces: new cluster is nil for subject %s", txt.Quote(subjectUID))
if merged = entity.NewFace(merge[0].SubjUID, merge[0].FaceSrc, merge.Embeddings()); merged == nil {
return merged, fmt.Errorf("faces: new cluster is nil for subject %s", txt.Quote(subjUID))
} else if merged = entity.FirstOrCreateFace(merged); merged == nil {
return merged, fmt.Errorf("faces: failed creating new cluster for subject %s", txt.Quote(subjectUID))
return merged, fmt.Errorf("faces: failed creating new cluster for subject %s", txt.Quote(subjUID))
} else if err := merged.MatchMarkers(append(merge.IDs(), "")); err != nil {
return merged, err
}
@ -153,9 +153,9 @@ func MergeFaces(merge entity.Faces) (merged *entity.Face, err error) {
if removed, err := PurgeOrphanFaces(merge.IDs()); err != nil {
return merged, err
} else if removed > 0 {
log.Debugf("faces: removed %d orphans for subject %s", removed, txt.Quote(subjectUID))
log.Debugf("faces: removed %d orphans for subject %s", removed, txt.Quote(subjUID))
} else {
log.Warnf("faces: failed removing merged clusters for subject %s", txt.Quote(subjectUID))
log.Warnf("faces: failed removing merged clusters for subject %s", txt.Quote(subjUID))
}
return merged, err
@ -172,7 +172,7 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) {
for _, f1 := range faces {
for _, f2 := range faces {
if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched {
if f1.SubjectUID == f2.SubjectUID {
if f1.SubjUID == f2.SubjUID {
continue
}
@ -182,14 +182,14 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) {
log.Infof("face %s: conflict at dist %f, Ø %f from %d samples, collision Ø %f", f1.ID, dist, r, f1.Samples, f1.CollisionRadius)
if f1.SubjectUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f1.ID, txt.Quote(f1.SubjectUID), f1.SubjectUID, entity.SrcString(f1.FaceSrc))
if f1.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f1.ID, txt.Quote(f1.SubjUID), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else {
log.Debugf("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
}
if f2.SubjectUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f2.ID, txt.Quote(f2.SubjectUID), f2.SubjectUID, entity.SrcString(f2.FaceSrc))
if f2.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f2.ID, txt.Quote(f2.SubjUID), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else {
log.Debugf("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc))
}

View file

@ -62,14 +62,14 @@ func TestMatchFaceMarkers(t *testing.T) {
} else if m == nil {
t.Fatal("marker is nil")
} else {
assert.Empty(t, m.SubjectUID)
assert.Empty(t, m.SubjUID)
}
// Reset subject_uid.
// Reset subj_uid.
if err := Db().Model(&entity.Marker{}).
Where("subject_src = ?", entity.SrcAuto).
Where("subject_uid = ?", "jqu0xs11qekk9jx8").
Updates(entity.Values{"SubjectUID": ""}).Error; err != nil {
Where("subj_src = ?", entity.SrcAuto).
Where("subj_uid = ?", "jqu0xs11qekk9jx8").
Updates(entity.Values{"SubjUID": ""}).Error; err != nil {
t.Fatal(err)
}
@ -86,7 +86,7 @@ func TestMatchFaceMarkers(t *testing.T) {
} else if m == nil {
t.Fatal("marker is nil")
} else {
assert.Equal(t, "jqu0xs11qekk9jx8", m.SubjectUID)
assert.Equal(t, "jqu0xs11qekk9jx8", m.SubjUID)
}
}
@ -152,7 +152,7 @@ func TestMergeFaces(t *testing.T) {
assert.Equal(t, "5LH5E35ZGUMF5AYLM42BIZH4DGQHJDAV", result.ID)
assert.Equal(t, entity.SrcManual, result.FaceSrc)
assert.Equal(t, "jqynvsf28rhn6b0c", result.SubjectUID)
assert.Equal(t, "jqynvsf28rhn6b0c", result.SubjUID)
assert.Equal(t, 2, result.Samples)
assert.Equal(t, 0.03948165743305488, result.SampleRadius)
assert.Equal(t, 0, result.Collisions)

View file

@ -44,7 +44,7 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
// Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" {
if subj, names, remaining := SearchSubjectUIDs(f.Query); len(subj) > 0 {
if subj, names, remaining := SearchSubjUIDs(f.Query); len(subj) > 0 {
f.Subject = strings.Join(subj, And)
log.Debugf("search: subject %s", txt.Quote(strings.Join(names, ", ")))
f.Query = remaining
@ -115,12 +115,12 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
// Filter for one or more subjects?
if f.Subject != "" {
for _, subj := range strings.Split(strings.ToLower(f.Subject), 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 subject_uid IN (?))",
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))
}
} else if f.Subjects != "" {
for _, where := range LikeAnyWord("s.subject_name", f.Subjects) {
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 JOIN %s s ON s.subject_uid = m.subject_uid WHERE (?))",
for _, where := range LikeAnyWord("s.subj_name", f.Subjects) {
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 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
}
}

View file

@ -29,7 +29,7 @@ func Markers(limit, offset int, markerType string, embeddings, subjects bool, ma
}
if subjects {
db = db.Where("subject_uid <> ''")
db = db.Where("subj_uid <> ''")
}
if !matchedBefore.IsZero() {
@ -119,8 +119,8 @@ func Embeddings(single, unclustered bool, size, score int) (result entity.Embedd
func RemoveInvalidMarkerReferences() (removed int64, err error) {
res := Db().
Model(&entity.Marker{}).
Where("marker_invalid = 1 AND (subject_uid <> '' OR face_id <> '')").
UpdateColumns(entity.Values{"subject_uid": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
Where("marker_invalid = 1 AND (subj_uid <> '' OR face_id <> '')").
UpdateColumns(entity.Values{"subj_uid": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
return res.RowsAffected, res.Error
}
@ -141,8 +141,8 @@ func RemoveNonExistentMarkerFaces() (removed int64, err error) {
func RemoveNonExistentMarkerSubjects() (removed int64, err error) {
res := Db().
Model(&entity.Marker{}).
Where(fmt.Sprintf("subject_uid <> '' AND subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Subject{}.TableName())).
UpdateColumns(entity.Values{"subject_uid": "", "matched_at": nil})
Where(fmt.Sprintf("subj_uid <> '' AND subj_uid NOT IN (SELECT subj_uid FROM %s)", entity.Subject{}.TableName())).
UpdateColumns(entity.Values{"subj_uid": "", "matched_at": nil})
return res.RowsAffected, res.Error
}
@ -182,7 +182,7 @@ func MarkersWithNonExistentReferences() (faces entity.Markers, subjects entity.M
// Find markers with invalid subject UIDs.
if res := Db().
Where(fmt.Sprintf("subject_uid <> '' AND subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Subject{}.TableName())).
Where(fmt.Sprintf("subj_uid <> '' AND subj_uid NOT IN (SELECT subj_uid FROM %s)", entity.Subject{}.TableName())).
Find(&subjects); res.Error != nil {
err = res.Error
}
@ -193,7 +193,7 @@ func MarkersWithNonExistentReferences() (faces entity.Markers, subjects entity.M
// MarkersWithSubjectConflict finds markers with conflicting subjects.
func MarkersWithSubjectConflict() (results entity.Markers, err error) {
err = Db().
Joins(fmt.Sprintf("JOIN %s f ON f.id = face_id AND f.subject_uid <> %s.subject_uid", entity.Face{}.TableName(), entity.Marker{}.TableName())).
Joins(fmt.Sprintf("JOIN %s f ON f.id = face_id AND f.subj_uid <> %s.subj_uid", entity.Face{}.TableName(), entity.Marker{}.TableName())).
Order("face_id").
Find(&results).Error
@ -203,8 +203,8 @@ func MarkersWithSubjectConflict() (results entity.Markers, err error) {
// ResetFaceMarkerMatches removes automatically added subject and face references from the markers table.
func ResetFaceMarkerMatches() (removed int64, err error) {
res := Db().Model(&entity.Marker{}).
Where("subject_src = ? AND marker_type = ?", entity.SrcAuto, entity.MarkerFace).
UpdateColumns(entity.Values{"marker_name": "", "subject_uid": "", "subject_src": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
Where("subj_src = ? AND marker_type = ?", entity.SrcAuto, entity.MarkerFace).
UpdateColumns(entity.Values{"marker_name": "", "subj_uid": "", "subj_src": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
return res.RowsAffected, res.Error
}

View file

@ -137,7 +137,7 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
// Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" {
if subj, names, remaining := SearchSubjectUIDs(f.Query); len(subj) > 0 {
if subj, names, remaining := SearchSubjUIDs(f.Query); len(subj) > 0 {
f.Subject = strings.Join(subj, And)
log.Debugf("people: searching for %s", txt.Quote(txt.JoinNames(names)))
f.Query = remaining
@ -222,12 +222,12 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
// Filter for one or more subjects?
if f.Subject != "" {
for _, subj := range strings.Split(strings.ToLower(f.Subject), 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 subject_uid IN (?))",
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))
}
} else if f.Subjects != "" {
for _, where := range LikeAnyWord("s.subject_name", f.Subjects) {
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 JOIN %s s ON s.subject_uid = m.subject_uid WHERE (?))",
for _, where := range LikeAnyWord("s.subj_name", f.Subjects) {
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 JOIN %s s ON s.subj_uid = m.subj_uid WHERE (?))",
entity.Marker{}.TableName(), entity.Subject{}.TableName()), gorm.Expr(where))
}
}

View file

@ -159,7 +159,7 @@ func UpdateSubjectPreviews() (err error) {
return Db().Table(entity.Subject{}.TableName()).
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",
"JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid",
entity.Marker{}.TableName(),
entity.Subject{}.TableName())+
` JOIN photos p ON f.photo_id = p.id
@ -172,7 +172,7 @@ func UpdateSubjectPreviews() (err error) {
err = 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' ",
"%s m WHERE m.subj_uid = %s.subj_uid AND m.subj_src = 'manual' ",
entity.Marker{}.TableName(),
entity.Subject{}.TableName())+
` AND m.file_hash <> '' ORDER BY m.size DESC LIMIT 1)

View file

@ -32,13 +32,14 @@ func PhotoSelection(f form.Selection) (results entity.Photos, err error) {
SELECT a.path FROM folders a WHERE a.folder_uid IN (?) UNION
SELECT b.path FROM folders a JOIN folders b ON b.path LIKE %s WHERE a.folder_uid IN (?))
OR photos.photo_uid IN (SELECT photo_uid FROM photos_albums WHERE hidden = 0 AND album_uid IN (?))
OR photos.id IN (SELECT f.photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid WHERE f.deleted_at IS NULL AND m.subj_uid IN (?))
OR photos.id IN (SELECT pl.photo_id FROM photos_labels pl JOIN labels l ON pl.label_id = l.id AND l.deleted_at IS NULL WHERE l.label_uid IN (?))
OR photos.id IN (SELECT pl.photo_id FROM photos_labels pl JOIN categories c ON c.label_id = pl.label_id JOIN labels lc ON lc.id = c.category_id AND lc.deleted_at IS NULL WHERE lc.label_uid IN (?))`,
concat)
concat, entity.Marker{}.TableName())
s := UnscopedDb().Table("photos").
Select("photos.*").
Where(where, f.Photos, f.Places, f.Files, f.Files, f.Files, f.Albums, f.Labels, f.Labels)
Where(where, f.Photos, f.Places, f.Files, f.Files, f.Files, f.Albums, f.Subjects, f.Labels, f.Labels)
if result := s.Scan(&results); result.Error != nil {
return results, result.Error
@ -71,16 +72,17 @@ func FileSelection(f form.Selection) (results entity.Files, err error) {
SELECT a.path FROM folders a WHERE a.folder_uid IN (?) UNION
SELECT b.path FROM folders a JOIN folders b ON b.path LIKE %s WHERE a.folder_uid IN (?))
OR photos.photo_uid IN (SELECT photo_uid FROM photos_albums WHERE hidden = 0 AND album_uid IN (?))
OR files.file_uid IN (SELECT file_uid FROM %s m WHERE m.subj_uid IN (?))
OR photos.id IN (SELECT pl.photo_id FROM photos_labels pl JOIN labels l ON pl.label_id = l.id AND l.deleted_at IS NULL WHERE l.label_uid IN (?))
OR photos.id IN (SELECT pl.photo_id FROM photos_labels pl JOIN categories c ON c.label_id = pl.label_id JOIN labels lc ON lc.id = c.category_id AND lc.deleted_at IS NULL WHERE lc.label_uid IN (?))`,
concat)
concat, entity.Marker{}.TableName())
s := UnscopedDb().Table("files").
Select("files.*").
Joins("JOIN photos ON photos.id = files.photo_id").
Where("photos.deleted_at IS NULL").
Where("files.file_missing = 0").
Where(where, f.Photos, f.Places, f.Files, f.Files, f.Files, f.Albums, f.Labels, f.Labels).
Where(where, f.Photos, f.Places, f.Files, f.Files, f.Files, f.Albums, f.Subjects, f.Labels, f.Labels).
Group("files.id")
if result := s.Scan(&results); result.Error != nil {

View file

@ -13,16 +13,16 @@ import (
// SubjectResult represents a subject search result.
type SubjectResult struct {
SubjectUID string `json:"UID"`
SubjectType string `json:"Type"`
SubjectSlug string `json:"Slug"`
SubjectName string `json:"Name"`
SubjectAlias string `json:"Alias,omitempty"`
Thumb string `json:"Thumb,omitempty"`
Favorite bool `json:"Favorite,omitempty"`
Private bool `json:"Private,omitempty"`
Excluded bool `json:"Excluded,omitempty"`
FileCount int `json:"Files,omitempty"`
SubjUID string `json:"UID"`
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:"Files"`
Thumb string `json:"Thumb"`
}
// SubjectResults represents subject search results.
@ -38,7 +38,7 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) {
// Base query.
s := UnscopedDb().Table(entity.Subject{}.TableName()).
Select("subject_uid, subject_slug, subject_name, subject_alias, subject_type, thumb, favorite, private, excluded, file_count")
Select("subj_uid, subj_slug, subj_name, subj_alias, subj_type, thumb, subj_favorite, subj_private, subj_excluded, file_count")
// Limit result count.
if f.Count > 0 && f.Count <= MaxResults {
@ -49,14 +49,20 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) {
// Set sort order.
switch f.Order {
case "name":
s = s.Order("subj_name")
case "count":
s = s.Order("file_count DESC")
case "added":
s = s.Order("created_at DESC")
case "relevance":
s = s.Order("subj_favorite DESC, subj_name")
default:
s = s.Order("subject_name")
s = s.Order("subj_favorite DESC, subj_name")
}
if f.ID != "" {
s = s.Where("subject_uid IN (?)", strings.Split(f.ID, Or))
s = s.Where("subj_uid IN (?)", strings.Split(f.ID, Or))
if result := s.Scan(&results); result.Error != nil {
return results, result.Error
@ -66,27 +72,28 @@ func SubjectSearch(f form.SubjectSearch) (results SubjectResults, err error) {
}
if f.Query != "" {
for _, where := range LikeAnyWord("subject_name", f.Query) {
for _, where := range LikeAnyWord("subj_name", f.Query) {
s = s.Where("(?)", gorm.Expr(where))
}
}
if f.Type != "" {
s = s.Where("subject_type IN (?)", strings.Split(f.Type, Or))
s = s.Where("subj_type IN (?)", strings.Split(f.Type, Or))
}
if f.Favorite {
s = s.Where("favorite = 1")
s = s.Where("subj_favorite = 1")
}
if f.Private {
s = s.Where("private = 1")
s = s.Where("subj_private = 1")
}
if f.Excluded {
s = s.Where("excluded = 1")
s = s.Where("subj_excluded = 1")
}
// Omit deleted rows.
s = s.Where("deleted_at IS NULL")
if result := s.Scan(&results); result.Error != nil {

View file

@ -12,7 +12,7 @@ import (
func TestSubjectSearch(t *testing.T) {
t.Run("FindAll", func(t *testing.T) {
results, err := SubjectSearch(form.SubjectSearch{Type: entity.SubjectPerson})
results, err := SubjectSearch(form.SubjectSearch{Type: entity.SubjPerson})
assert.NoError(t, err)
assert.LessOrEqual(t, 3, len(results))
})

View file

@ -14,9 +14,9 @@ import (
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("favorite, subject_name").
Select("subj_uid, subj_name, subj_alias, subj_favorite ").
Where("deleted_at IS NULL AND subj_type = ?", entity.SubjPerson).
Order("subj_favorite, subj_name").
Limit(2000).Offset(0).
Scan(&people).Error
@ -28,7 +28,7 @@ func PeopleCount() (count int, err error) {
err = Db().
Table(entity.Subject{}.TableName()).
Where("deleted_at IS NULL").
Where("subject_type = ?", entity.SubjectPerson).
Where("subj_type = ?", entity.SubjPerson).
Count(&count).Error
return count, err
@ -38,7 +38,7 @@ func PeopleCount() (count int, err error) {
func Subjects(limit, offset int) (result entity.Subjects, err error) {
stmt := Db()
stmt = stmt.Order("subject_name").Limit(limit).Offset(offset)
stmt = stmt.Order("subj_name").Limit(limit).Offset(offset)
err = stmt.Find(&result).Error
return result, err
@ -57,7 +57,7 @@ func SubjectMap() (result map[string]entity.Subject, err error) {
}
for _, s := range subj {
result[s.SubjectUID] = s
result[s.SubjUID] = s
}
return result, err
@ -66,9 +66,9 @@ func SubjectMap() (result map[string]entity.Subject, err error) {
// RemoveDanglingMarkerSubjects permanently deletes dangling marker subjects from the index.
func RemoveDanglingMarkerSubjects() (removed int64, err error) {
res := UnscopedDb().
Where("subject_src = ?", entity.SrcMarker).
Where(fmt.Sprintf("subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Face{}.TableName())).
Where(fmt.Sprintf("subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Marker{}.TableName())).
Where("subj_src = ?", entity.SrcMarker).
Where(fmt.Sprintf("subj_uid NOT IN (SELECT subj_uid FROM %s)", entity.Face{}.TableName())).
Where(fmt.Sprintf("subj_uid NOT IN (SELECT subj_uid FROM %s)", entity.Marker{}.TableName())).
Delete(&entity.Subject{})
return res.RowsAffected, res.Error
@ -79,7 +79,7 @@ func CreateMarkerSubjects() (affected int64, err error) {
var markers entity.Markers
if err := Db().
Where("subject_uid = '' AND marker_name <> '' AND subject_src <> ?", entity.SrcAuto).
Where("subj_uid = '' AND marker_name <> '' AND subj_src <> ?", entity.SrcAuto).
Where("marker_invalid = 0 AND marker_type = ?", entity.MarkerFace).
Order("marker_name").
Find(&markers).Error; err != nil {
@ -94,7 +94,7 @@ func CreateMarkerSubjects() (affected int64, err error) {
for _, m := range markers {
if name == m.MarkerName && subj != nil {
// Do nothing.
} else if subj = entity.NewSubject(m.MarkerName, entity.SubjectPerson, entity.SrcMarker); subj == nil {
} else if subj = entity.NewSubject(m.MarkerName, entity.SubjPerson, entity.SrcMarker); subj == nil {
log.Errorf("faces: subject should not be nil - bug?")
continue
} else if subj = entity.FirstOrCreateSubject(subj); subj == nil {
@ -106,13 +106,13 @@ func CreateMarkerSubjects() (affected int64, err error) {
name = m.MarkerName
if err := m.Updates(entity.Values{"SubjectUID": subj.SubjectUID, "Review": false}); err != nil {
if err := m.Updates(entity.Values{"SubjUID": subj.SubjUID, "MarkerReview": false}); err != nil {
return affected, err
}
if m.FaceID == "" {
continue
} else if err := Db().Model(&entity.Face{}).Where("id = ? AND subject_uid = ''", m.FaceID).Update("SubjectUID", subj.SubjectUID).Error; err != nil {
} else if err := Db().Model(&entity.Face{}).Where("id = ? AND subj_uid = ''", m.FaceID).Update("SubjUID", subj.SubjUID).Error; err != nil {
return affected, err
}
}
@ -120,21 +120,21 @@ func CreateMarkerSubjects() (affected int64, err error) {
return affected, err
}
// SearchSubjectUIDs finds subject UIDs matching the search string, and removes names from the remaining query.
func SearchSubjectUIDs(s string) (result []string, names []string, remaining string) {
// 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 {
SubjectUID string
SubjectName string
SubjectAlias string
SubjUID string
SubjName string
SubjAlias string
}
var matches []Matches
wheres := LikeAllNames(Cols{"subject_name", "subject_alias"}, s)
wheres := LikeAllNames(Cols{"subj_name", "subj_alias"}, s)
if len(wheres) == 0 {
return result, names, s
@ -155,16 +155,16 @@ func SearchSubjectUIDs(s string) (result []string, names []string, remaining str
}
for _, m := range matches {
subj = append(subj, m.SubjectUID)
names = append(names, m.SubjectName)
subj = append(subj, m.SubjUID)
names = append(names, m.SubjName)
for _, r := range txt.Words(strings.ToLower(m.SubjectName)) {
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.SubjectAlias)) {
for _, r := range txt.Words(strings.ToLower(m.SubjAlias)) {
if len(r) > 1 {
remaining = strings.ReplaceAll(remaining, r, "")
}

View file

@ -72,9 +72,9 @@ func TestCreateMarkerSubjects(t *testing.T) {
assert.LessOrEqual(t, int64(0), affected)
}
func TestSearchSubjectUIDs(t *testing.T) {
func TestSearchSubjUIDs(t *testing.T) {
t.Run("john & his | cats", func(t *testing.T) {
result, names, remaining := SearchSubjectUIDs("john & his | cats")
result, names, remaining := SearchSubjUIDs("john & his | cats")
if len(result) != 1 {
t.Fatal("expected one result")
@ -85,11 +85,11 @@ func TestSearchSubjectUIDs(t *testing.T) {
}
})
t.Run("xxx", func(t *testing.T) {
result, _, _ := SearchSubjectUIDs("xxx")
result, _, _ := SearchSubjUIDs("xxx")
assert.Empty(t, result)
})
t.Run("empty string", func(t *testing.T) {
result, _, _ := SearchSubjectUIDs("")
result, _, _ := SearchSubjUIDs("")
assert.Empty(t, result)
})
}

View file

@ -103,6 +103,9 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetSubjects(v1)
api.GetSubject(v1)
api.UpdateSubject(v1)
api.LikeSubject(v1)
api.DislikeSubject(v1)
api.LabelCover(v1)
api.GetLabels(v1)