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 = []; this.values.people = [];
} }
if (!data || !data.entities) {
console.warn("empty event data", ev, data);
return;
}
switch (type) { switch (type) {
case "created": case "created":
this.values.people.unshift(...data.entities); 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 PAlbumToolbar from "./album/toolbar.vue";
import PLabelClipboard from "./label/clipboard.vue"; import PLabelClipboard from "./label/clipboard.vue";
import PFileClipboard from "./file/clipboard.vue"; import PFileClipboard from "./file/clipboard.vue";
import PSubjectClipboard from "./subject/clipboard.vue";
import PAboutFooter from "./footer.vue"; import PAboutFooter from "./footer.vue";
const components = {}; const components = {};
@@ -63,6 +64,7 @@ components.install = (Vue) => {
Vue.component("PAlbumToolbar", PAlbumToolbar); Vue.component("PAlbumToolbar", PAlbumToolbar);
Vue.component("PLabelClipboard", PLabelClipboard); Vue.component("PLabelClipboard", PLabelClipboard);
Vue.component("PFileClipboard", PFileClipboard); Vue.component("PFileClipboard", PFileClipboard);
Vue.component("PSubjectClipboard", PSubjectClipboard);
Vue.component("PAboutFooter", PAboutFooter); Vue.component("PAboutFooter", PAboutFooter);
}; };

View File

@@ -223,6 +223,20 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </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" <v-list-tile v-if="isMini" v-show="$config.feature('places')" :to="{ name: 'places' }" class="nav-places"
@click.stop=""> @click.stop="">
<v-list-tile-action :title="$gettext('Places')"> <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" :transition="false"
aspect-ratio="1" aspect-ratio="1"
class="accent lighten-2"> 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')" icon flat small absolute :title="$gettext('Remove')"
@click.stop.prevent="onReject(marker)"> @click.stop.prevent="onReject(marker)">
<v-icon color="white" class="action-reject">clear</v-icon> <v-icon color="white" class="action-reject">clear</v-icon>
@@ -43,11 +43,11 @@
large depressed block :round="false" large depressed block :round="false"
class="action-approve text-xs-center" class="action-approve text-xs-center"
:title="$gettext('Approve')" @click.stop="onApprove(marker)"> :title="$gettext('Approve')" @click.stop="onApprove(marker)">
<v-icon dark>check</v-icon> <v-icon dark>undo</v-icon>
</v-btn> </v-btn>
</v-flex> </v-flex>
</v-layout> </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-flex xs12 class="text-xs-left pa-0">
<v-text-field <v-text-field
v-model="marker.Name" v-model="marker.Name"

View File

@@ -53,8 +53,8 @@ export class Marker extends RestModel {
H: 0.0, H: 0.0,
CropID: "", CropID: "",
FaceID: "", FaceID: "",
SubjectSrc: "", SubjSrc: "",
SubjectUID: "", SubjUID: "",
Score: 0, Score: 0,
Size: 0, Size: 0,
}; };
@@ -118,9 +118,9 @@ export class Marker extends RestModel {
return Promise.resolve(this); 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) => return Api.put(this.getEntityResource(), payload).then((resp) =>
Promise.resolve(this.setValues(resp.data)) 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: { methods: {
viewType() { viewType() {
let queryParam = this.$route.query['view']; let queryParam = this.$route.query["view"];
let storedType = window.localStorage.getItem("photo_view"); let storedType = window.localStorage.getItem("photo_view");
if (queryParam) { if (queryParam) {
@@ -178,7 +178,7 @@ export default {
return 'cards'; return 'cards';
}, },
sortOrder() { sortOrder() {
let queryParam = this.$route.query['order']; let queryParam = this.$route.query["order"];
let storedType = window.localStorage.getItem("photo_order"); let storedType = window.localStorage.getItem("photo_order");
if (queryParam) { if (queryParam) {
@@ -188,7 +188,7 @@ export default {
return storedType; return storedType;
} }
return 'newest'; return "newest";
}, },
openLocation(index) { openLocation(index) {
const photo = this.results[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 Files from "pages/library/files.vue";
import Errors from "pages/library/errors.vue"; import Errors from "pages/library/errors.vue";
import Labels from "pages/labels.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 Library from "pages/library.vue";
import Settings from "pages/settings.vue"; import Settings from "pages/settings.vue";
import Login from "pages/login.vue"; import Login from "pages/login.vue";
@@ -261,7 +261,7 @@ export default [
{ {
name: "people", name: "people",
path: "/people", path: "/people",
component: People, component: Subjects,
meta: { title: $gettext("People"), auth: true }, 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) 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" "github.com/photoprism/photoprism/pkg/txt"
) )
// GetLabels finds and returns labels as JSON.
//
// GET /api/v1/labels // GET /api/v1/labels
func GetLabels(router *gin.RouterGroup) { func GetLabels(router *gin.RouterGroup) {
router.GET("/labels", func(c *gin.Context) { 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 // PUT /api/v1/labels/:uid
func UpdateLabel(router *gin.RouterGroup) { func UpdateLabel(router *gin.RouterGroup) {
router.PUT("/labels/:uid", func(c *gin.Context) { 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 // POST /api/v1/labels/:uid/like
// //
// Parameters: // Parameters:
@@ -123,6 +129,8 @@ func LikeLabel(router *gin.RouterGroup) {
}) })
} }
// DislikeLabel removes the favorite flag from a label.
//
// DELETE /api/v1/labels/:uid/like // DELETE /api/v1/labels/:uid/like
// //
// Parameters: // Parameters:

View File

@@ -93,7 +93,7 @@ func UpdateMarker(router *gin.RouterGroup) {
log.Errorf("photo: %s (save marker form)", err) log.Errorf("photo: %s (save marker form)", err)
AbortSaveFailed(c) AbortSaveFailed(c)
return 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 { if res, err := service.Faces().Optimize(); err != nil {
log.Errorf("faces: %s (optimize)", err) log.Errorf("faces: %s (optimize)", err)
} else if res.Merged > 0 { } else if res.Merged > 0 {

View File

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
@@ -68,6 +69,111 @@ func GetSubject(router *gin.RouterGroup) {
} else { } else {
c.JSON(http.StatusOK, subj) 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_dev5",
"subjects_dev6", "subjects_dev6",
"subjects_dev7", "subjects_dev7",
"subjects_dev8",
"markers_dev1", "markers_dev1",
"markers_dev2", "markers_dev2",
"markers_dev3", "markers_dev3",
@@ -28,6 +29,7 @@ var DeprecatedTables = Deprecated{
"markers_dev5", "markers_dev5",
"markers_dev6", "markers_dev6",
"markers_dev7", "markers_dev7",
"markers_dev8",
"faces_dev1", "faces_dev1",
"faces_dev2", "faces_dev2",
"faces_dev3", "faces_dev3",
@@ -35,4 +37,5 @@ var DeprecatedTables = Deprecated{
"faces_dev5", "faces_dev5",
"faces_dev6", "faces_dev6",
"faces_dev7", "faces_dev7",
"faces_dev8",
} }

View File

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

View File

@@ -133,7 +133,7 @@ func TestNewFace(t *testing.T) {
r := NewFace("123", SrcAuto, e) r := NewFace("123", SrcAuto, e)
assert.Equal(t, "", r.FaceSrc) 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)) assert.Nil(t, FindFace(m.ID))
m.Save() m.Save()
assert.NotNil(t, FindFace(m.ID)) 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) { func TestFace_Update(t *testing.T) {
@@ -193,11 +193,11 @@ func TestFace_Update(t *testing.T) {
assert.Nil(t, FindFace(m.ID)) assert.Nil(t, FindFace(m.ID))
m.Save() m.Save()
assert.NotNil(t, FindFace(m.ID)) 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 := FindFace(m.ID)
m2.Update("SubjectUID", "new") m2.Update("SubjUID", "new")
assert.Equal(t, "new", FindFace(m.ID).SubjectUID) assert.Equal(t, "new", FindFace(m.ID).SubjUID)
} }
func TestFace_RefreshPhotos(t *testing.T) { func TestFace_RefreshPhotos(t *testing.T) {
@@ -212,12 +212,12 @@ func TestFirstOrCreateFace(t *testing.T) {
t.Run("create new face", func(t *testing.T) { t.Run("create new face", func(t *testing.T) {
m := NewFace("12345unique", SrcAuto, Embeddings{Embedding{99}, Embedding{2}}) m := NewFace("12345unique", SrcAuto, Embeddings{Embedding{99}, Embedding{2}})
r := FirstOrCreateFace(m) r := FirstOrCreateFace(m)
assert.Equal(t, "12345unique", r.SubjectUID) assert.Equal(t, "12345unique", r.SubjUID)
}) })
t.Run("return existing entity", func(t *testing.T) { t.Run("return existing entity", func(t *testing.T) {
m := FaceFixtures.Pointer("joe-biden") m := FaceFixtures.Pointer("joe-biden")
r := FirstOrCreateFace(m) r := FirstOrCreateFace(m)
assert.Equal(t, "jqy3y652h8njw0sx", r.SubjectUID) assert.Equal(t, "jqy3y652h8njw0sx", r.SubjUID)
assert.Equal(t, 33, r.Samples) 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. // AddFace adds a face marker to the file.
func (m *File) AddFace(f face.Face, subjectUID string) { func (m *File) AddFace(f face.Face, subjUID string) {
marker := *NewFaceMarker(f, *m, subjectUID) marker := *NewFaceMarker(f, *m, subjUID)
if markers := m.Markers(); !markers.Contains(marker) { if markers := m.Markers(); !markers.Contains(marker) {
markers.Append(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"` MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"` MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"`
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,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"` MarkerReview bool `json:"Review" yaml:"Review,omitempty"`
SubjectSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subject_uid_src;default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"` SubjUID string `gorm:"type:VARBINARY(42);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
subject *Subject `gorm:"foreignkey:SubjectUID;association_foreignkey:SubjectUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"` 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"` FaceID string `gorm:"type:VARBINARY(42);index;" json:"FaceID" yaml:"FaceID,omitempty"`
FaceDist float64 `gorm:"default:-1" json:"FaceDist" yaml:"FaceDist,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"` 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"` H float32 `gorm:"type:FLOAT;" json:"H" yaml:"H,omitempty"`
Size int `gorm:"default:-1" json:"Size" yaml:"Size,omitempty"` Size int `gorm:"default:-1" json:"Size" yaml:"Size,omitempty"`
Score int `gorm:"type:SMALLINT" json:"Score" yaml:"Score,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"` MatchedAt *time.Time `sql:"index" json:"MatchedAt" yaml:"MatchedAt,omitempty"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@@ -57,7 +57,7 @@ type Marker struct {
// TableName returns the entity database table name. // TableName returns the entity database table name.
func (Marker) TableName() string { 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. // 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. // 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{ m := &Marker{
FileUID: file.FileUID, FileUID: file.FileUID,
FileHash: file.FileHash, FileHash: file.FileHash,
CropArea: area.String(), CropArea: area.String(),
MarkerSrc: markerSrc, MarkerSrc: markerSrc,
MarkerType: markerType, MarkerType: markerType,
SubjectUID: subjectUID, SubjUID: subjUID,
X: area.X, X: area.X,
Y: area.Y, Y: area.Y,
W: area.W, W: area.W,
@@ -89,12 +89,12 @@ func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType stri
} }
// NewFaceMarker creates a new entity. // NewFaceMarker creates a new entity.
func NewFaceMarker(f face.Face, file File, subjectUID string) *Marker { func NewFaceMarker(f face.Face, file File, subjUID string) *Marker {
m := NewMarker(file, f.CropArea(), subjectUID, SrcImage, MarkerFace) m := NewMarker(file, f.CropArea(), subjUID, SrcImage, MarkerFace)
m.Size = f.Size() m.Size = f.Size()
m.Score = f.Score m.Score = f.Score
m.Review = f.Score < 30 m.MarkerReview = f.Score < 30
m.FaceDist = -1 m.FaceDist = -1
m.EmbeddingsJSON = f.EmbeddingsJSON() m.EmbeddingsJSON = f.EmbeddingsJSON()
m.LandmarksJSON = f.RelativeLandmarksJSON() m.LandmarksJSON = f.RelativeLandmarksJSON()
@@ -121,13 +121,13 @@ func (m *Marker) SaveForm(f form.Marker) error {
changed = true changed = true
} }
if m.Review != f.Review { if m.MarkerReview != f.MarkerReview {
m.Review = f.Review m.MarkerReview = f.MarkerReview
changed = true changed = true
} }
if f.SubjectSrc == SrcManual && strings.TrimSpace(f.MarkerName) != "" { if f.SubjSrc == SrcManual && strings.TrimSpace(f.MarkerName) != "" {
m.SubjectSrc = SrcManual m.SubjSrc = SrcManual
m.MarkerName = txt.Title(txt.Clip(f.MarkerName, txt.ClipDefault)) m.MarkerName = txt.Title(txt.Clip(f.MarkerName, txt.ClipDefault))
if err := m.SyncSubject(true); err != nil { 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? // 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. // Don't skip if subject wasn't set manually, or subjects match.
} else if reported, err := f.ResolveCollision(m.Embeddings()); err != nil { } else if reported, err := f.ResolveCollision(m.Embeddings()); err != nil {
return false, err return false, err
} else if reported { } 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 return false, nil
} else { } else {
return false, nil return false, nil
} }
// Update face with known subject from marker? // 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. // 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 return false, err
} }
@@ -194,7 +194,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
m.face = f m.face = f
// Skip update if the same face is already set. // 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. // Update matching timestamp.
m.MatchedAt = TimePointer() m.MatchedAt = TimePointer()
return false, m.Updates(Values{"MatchedAt": m.MatchedAt}) 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. // Remember current values for comparison.
faceID := m.FaceID faceID := m.FaceID
subjectUID := m.SubjectUID subjUID := m.SubjUID
SubjectSrc := m.SubjectSrc subjSrc := m.SubjSrc
m.FaceID = f.ID m.FaceID = f.ID
m.FaceDist = dist m.FaceDist = dist
@@ -211,7 +211,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
if m.FaceDist < 0 { if m.FaceDist < 0 {
faceEmbedding := f.Embedding() faceEmbedding := f.Embedding()
// Calculate smallest distance to embeddings. // Calculate the smallest distance to embeddings.
for _, e := range m.Embeddings() { for _, e := range m.Embeddings() {
if len(e) != len(faceEmbedding) { if len(e) != len(faceEmbedding) {
continue continue
@@ -223,8 +223,8 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
} }
} }
if f.SubjectUID != "" { if f.SubjUID != "" {
m.SubjectUID = f.SubjectUID m.SubjUID = f.SubjUID
} }
if err = m.SyncSubject(false); err != nil { 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? // 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. // Not needed.
} else if err = f.SetSubjectUID(m.SubjectUID); err != nil { } else if err = f.SetSubjectUID(m.SubjUID); err != nil {
return false, err 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. // Update matching timestamp.
m.MatchedAt = TimePointer() 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 return false, err
} else if !updated { } else if !updated {
return false, nil return false, nil
@@ -261,20 +261,20 @@ func (m *Marker) SyncSubject(updateRelated bool) (err error) {
subj := m.Subject() subj := m.Subject()
if subj == nil || m.SubjectSrc == SrcAuto { if subj == nil || m.SubjSrc == SrcAuto {
return nil return nil
} }
// Update subject with marker name? // Update subject with marker name?
if m.MarkerName == "" || subj.SubjectName == m.MarkerName { if m.MarkerName == "" || subj.SubjName == m.MarkerName {
// Do nothing. // Do nothing.
} else if subj, err = subj.UpdateName(m.MarkerName); err != nil { } else if subj, err = subj.UpdateName(m.MarkerName); err != nil {
return err return err
} else if subj != nil { } else if subj != nil {
// Update subject fields in case it was merged. // Update subject fields in case it was merged.
m.subject = subj m.subject = subj
m.SubjectUID = subj.SubjectUID m.SubjUID = subj.SubjUID
m.MarkerName = subj.SubjectName m.MarkerName = subj.SubjName
} }
// Create known face for subject? // Create known face for subject?
@@ -285,21 +285,21 @@ func (m *Marker) SyncSubject(updateRelated bool) (err error) {
} }
// Update related markers? // Update related markers?
if m.FaceID == "" || m.SubjectUID == "" { if m.FaceID == "" || m.SubjUID == "" {
// Do nothing. // 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) return fmt.Errorf("%s (update known face)", err)
} else if !updateRelated { } else if !updateRelated {
return nil return nil
} else if err := Db().Model(&Marker{}). } else if err := Db().Model(&Marker{}).
Where("marker_uid <> ?", m.MarkerUID). Where("marker_uid <> ?", m.MarkerUID).
Where("face_id = ?", m.FaceID). Where("face_id = ?", m.FaceID).
Where("subject_src = ?", SrcAuto). Where("subj_src = ?", SrcAuto).
Where("subject_uid <> ?", m.SubjectUID). Where("subj_uid <> ?", m.SubjUID).
Updates(Values{"SubjectUID": m.SubjectUID, "SubjectSrc": SrcAuto, "Review": false}).Error; err != nil { Updates(Values{"SubjUID": m.SubjUID, "SubjSrc": SrcAuto, "MarkerReview": false}).Error; err != nil {
return fmt.Errorf("%s (update related markers)", err) return fmt.Errorf("%s (update related markers)", err)
} else if res.RowsAffected > 0 && m.face != nil { } 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() return m.face.RefreshPhotos()
} }
@@ -342,7 +342,7 @@ func (m *Marker) SubjectName() string {
if m.MarkerName != "" { if m.MarkerName != "" {
return m.MarkerName return m.MarkerName
} else if s := m.Subject(); s != nil { } else if s := m.Subject(); s != nil {
return s.SubjectName return s.SubjName
} }
return "" return ""
@@ -351,27 +351,27 @@ func (m *Marker) SubjectName() string {
// Subject returns the matching subject or nil. // Subject returns the matching subject or nil.
func (m *Marker) Subject() (subj *Subject) { func (m *Marker) Subject() (subj *Subject) {
if m.subject != nil { if m.subject != nil {
if m.SubjectUID == m.subject.SubjectUID { if m.SubjUID == m.subject.SubjUID {
return m.subject return m.subject
} }
} }
// Create subject? // Create subject?
if m.SubjectSrc != SrcAuto && m.MarkerName != "" && m.SubjectUID == "" { if m.SubjSrc != SrcAuto && m.MarkerName != "" && m.SubjUID == "" {
if subj = NewSubject(m.MarkerName, SubjectPerson, m.SubjectSrc); subj == nil { if subj = NewSubject(m.MarkerName, SubjPerson, m.SubjSrc); subj == nil {
return nil return nil
} else if subj = FirstOrCreateSubject(subj); subj == nil { } else if subj = FirstOrCreateSubject(subj); subj == nil {
log.Debugf("marker: invalid subject %s", txt.Quote(m.MarkerName)) log.Debugf("marker: invalid subject %s", txt.Quote(m.MarkerName))
return nil return nil
} else { } else {
m.subject = subj m.subject = subj
m.SubjectUID = subj.SubjectUID m.SubjUID = subj.SubjUID
} }
return m.subject return m.subject
} }
m.subject = FindSubject(m.SubjectUID) m.subject = FindSubject(m.SubjUID)
return m.subject return m.subject
} }
@@ -384,7 +384,7 @@ func (m *Marker) ClearSubject(src string) error {
} }
// Update index & resolve collisions. // 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 return err
} else if m.face == nil { } else if m.face == nil {
m.subject = nil m.subject = nil
@@ -411,14 +411,14 @@ func (m *Marker) Face() (f *Face) {
} }
// Add face if size // 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 { 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) log.Debugf("faces: skipped adding face for low-quality marker %s, size %d, score %d", m.MarkerUID, m.Size, m.Score)
return nil return nil
} else if emb := m.Embeddings(); len(emb) == 0 { } else if emb := m.Embeddings(); len(emb) == 0 {
log.Warnf("marker: %s has no embeddings", m.MarkerUID) log.Warnf("marker: %s has no embeddings", m.MarkerUID)
return nil 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) log.Warnf("marker: failed adding face for id %s", m.MarkerUID)
return nil return nil
} else if f = FirstOrCreateFace(f); f == nil { } else if f = FirstOrCreateFace(f); f == nil {
@@ -452,9 +452,9 @@ func (m *Marker) ClearFace() (updated bool, err error) {
m.MatchedAt = TimePointer() m.MatchedAt = TimePointer()
// Remove subject if set automatically. // Remove subject if set automatically.
if m.SubjectSrc == SrcAuto { if m.SubjSrc == SrcAuto {
m.SubjectUID = "" m.SubjUID = ""
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "MatchedAt": m.MatchedAt}) err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "SubjUID": "", "MatchedAt": m.MatchedAt})
} else { } else {
err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "MatchedAt": m.MatchedAt}) err = m.Updates(Values{"FaceID": "", "FaceDist": -1.0, "MatchedAt": m.MatchedAt})
} }

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ func UpdatePhotoCounts() (err error) {
if err = Db().Table(Subject{}.TableName()). if err = Db().Table(Subject{}.TableName()).
UpdateColumn("file_count", gorm.Expr("(SELECT COUNT(*) FROM files f "+ UpdateColumn("file_count", gorm.Expr("(SELECT COUNT(*) FROM files f "+
fmt.Sprintf( 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(), Marker{}.TableName(),
Subject{}.TableName())+ Subject{}.TableName())+
" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL)")).Error; err != nil { " 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. // Subject represents a named photo subject, typically a person.
type Subject struct { 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"` 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"` 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"` SubjType 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"` SubjSrc string `gorm:"type:VARBINARY(8);default:''" json:"Src,omitempty" yaml:"Src,omitempty"`
SubjectSlug string `gorm:"type:VARBINARY(255);index;default:''" json:"Slug" yaml:"-"` SubjSlug string `gorm:"type:VARBINARY(255);index;default:''" json:"Slug" yaml:"-"`
SubjectName string `gorm:"type:VARCHAR(255);unique_index;default:''" json:"Name" yaml:"Name"` SubjName string `gorm:"type:VARCHAR(255);unique_index;default:''" json:"Name" yaml:"Name"`
SubjectAlias string `gorm:"type:VARCHAR(255);default:''" json:"Alias" yaml:"Alias"` SubjAlias string `gorm:"type:VARCHAR(255);default:''" json:"Alias" yaml:"Alias"`
SubjectBio string `gorm:"type:TEXT;default:''" json:"Bio" yaml:"Bio,omitempty"` SubjBio string `gorm:"type:TEXT;default:''" json:"Bio" yaml:"Bio,omitempty"`
SubjectNotes string `gorm:"type:TEXT;default:''" json:"Notes,omitempty" yaml:"Notes,omitempty"` SubjNotes string `gorm:"type:TEXT;default:''" json:"Notes,omitempty" yaml:"Notes,omitempty"`
Favorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"` SubjFavorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"`
Private bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"` SubjPrivate bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"`
Excluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"` SubjExcluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"`
FileCount int `gorm:"default:0" json:"Files" yaml:"-"` FileCount int `gorm:"default:0" json:"Files" yaml:"-"`
MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"` MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"` CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
@@ -42,38 +42,38 @@ type Subject struct {
// TableName returns the entity database table name. // TableName returns the entity database table name.
func (Subject) TableName() string { 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. // BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Subject) BeforeCreate(scope *gorm.Scope) error { func (m *Subject) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.SubjectUID, 'j') { if rnd.IsUID(m.SubjUID, 'j') {
return nil return nil
} }
return scope.SetColumn("SubjectUID", rnd.PPID('j')) return scope.SetColumn("SubjUID", rnd.PPID('j'))
} }
// NewSubject returns a new entity. // NewSubject returns a new entity.
func NewSubject(name, subjectType, subjectSrc string) *Subject { func NewSubject(name, subjType, subjSrc string) *Subject {
if subjectType == "" { if subjType == "" {
subjectType = SubjectPerson subjType = SubjPerson
} }
subjectName := txt.Title(txt.Clip(name, txt.ClipDefault)) subjName := txt.Title(txt.Clip(name, txt.ClipDefault))
subjectSlug := slug.Make(txt.Clip(name, txt.ClipSlug)) subjSlug := slug.Make(txt.Clip(name, txt.ClipSlug))
// Name is required. // Name is required.
if subjectName == "" || subjectSlug == "" { if subjName == "" || subjSlug == "" {
return nil return nil
} }
result := &Subject{ result := &Subject{
SubjectSlug: subjectSlug, SubjSlug: subjSlug,
SubjectName: subjectName, SubjName: subjName,
SubjectType: subjectType, SubjType: subjType,
SubjectSrc: subjectSrc, SubjSrc: subjSrc,
FileCount: 1, FileCount: 1,
} }
return result return result
@@ -101,10 +101,12 @@ func (m *Subject) Delete() error {
return nil 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() { if m.IsPerson() {
event.EntitiesDeleted("people", []string{m.SubjectUID}) event.EntitiesDeleted("people", []string{m.SubjUID})
event.Publish("count.people", event.Data{ event.Publish("count.people", event.Data{
"count": -1, "count": -1,
}) })
@@ -123,11 +125,12 @@ func (m *Subject) Restore() error {
if m.Deleted() { if m.Deleted() {
m.DeletedAt = nil 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() { if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()}) event.EntitiesCreated("people", []*Person{m.Person()})
event.Publish("count.people", event.Data{ event.Publish("count.people", event.Data{
"count": 1, "count": 1,
}) })
@@ -153,14 +156,16 @@ func (m *Subject) Updates(values interface{}) error {
func FirstOrCreateSubject(m *Subject) *Subject { func FirstOrCreateSubject(m *Subject) *Subject {
if m == nil { if m == nil {
return nil return nil
} else if m.SubjectName == "" { } else if m.SubjName == "" {
return nil return nil
} }
if found := FindSubjectByName(m.SubjectName); found != nil { if found := FindSubjectByName(m.SubjName); found != nil {
return found return found
} else if createErr := m.Create(); createErr == nil { } 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() { if m.IsPerson() {
event.EntitiesCreated("people", []*Person{m.Person()}) event.EntitiesCreated("people", []*Person{m.Person()})
@@ -170,10 +175,10 @@ func FirstOrCreateSubject(m *Subject) *Subject {
} }
return m return m
} else if found = FindSubjectByName(m.SubjectName); found != nil { } else if found = FindSubjectByName(m.SubjName); found != nil {
return found return found
} else { } 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 return nil
@@ -187,7 +192,7 @@ func FindSubject(s string) *Subject {
result := Subject{} result := Subject{}
db := Db().Where("subject_uid = ?", s) db := Db().Where("subj_uid = ?", s)
if err := db.First(&result).Error; err != nil { if err := db.First(&result).Error; err != nil {
return nil return nil
@@ -205,7 +210,7 @@ func FindSubjectByName(s string) *Subject {
result := Subject{} result := Subject{}
// Search database. // 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 { if err := db.First(&result).Error; err != nil {
return nil return nil
@@ -213,9 +218,9 @@ func FindSubjectByName(s string) *Subject {
// Restore if currently deleted. // Restore if currently deleted.
if err := result.Restore(); err != nil { 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 { } else {
log.Debugf("subject: %s restored", result.SubjectUID) log.Debugf("subject: %s restored", result.SubjUID)
} }
return &result return &result
@@ -223,7 +228,7 @@ func FindSubjectByName(s string) *Subject {
// IsPerson tests if the subject is a person. // IsPerson tests if the subject is a person.
func (m *Subject) IsPerson() bool { func (m *Subject) IsPerson() bool {
return m.SubjectType == SubjectPerson return m.SubjType == SubjPerson
} }
// Person creates and returns a Person based on this subject. // 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") return fmt.Errorf("subject: name must not be empty")
} }
m.SubjectName = txt.Title(newName) m.SubjName = txt.Title(newName)
m.SubjectSlug = slug.Make(txt.Clip(name, txt.ClipSlug)) m.SubjSlug = slug.Make(txt.Clip(name, txt.ClipSlug))
return nil return nil
} }
@@ -249,15 +254,17 @@ func (m *Subject) SetName(name string) error {
func (m *Subject) UpdateName(name string) (*Subject, error) { func (m *Subject) UpdateName(name string) (*Subject, error) {
if err := m.SetName(name); err != nil { if err := m.SetName(name); err != nil {
return m, err return m, err
} else if err := m.Updates(Values{"SubjectName": m.SubjectName, "SubjectSlug": m.SubjectSlug}); err == nil { } else if err := m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err == nil {
log.Infof("subject: renamed %s %s", m.SubjectType, txt.Quote(m.SubjectName)) log.Infof("subject: renamed %s %s", m.SubjType, txt.Quote(m.SubjName))
event.EntitiesUpdated("subjects", []*Subject{m})
if m.IsPerson() { if m.IsPerson() {
event.EntitiesUpdated("people", []*Person{m.Person()}) event.EntitiesUpdated("people", []*Person{m.Person()})
} }
return m, m.UpdateMarkerNames() return m, m.UpdateMarkerNames()
} else if existing := FindSubjectByName(m.SubjectName); existing == nil { } else if existing := FindSubjectByName(m.SubjName); existing == nil {
return m, err return m, err
} else { } else {
return existing, m.MergeWith(existing) return existing, m.MergeWith(existing)
@@ -266,16 +273,16 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
// UpdateMarkerNames updates related marker names. // UpdateMarkerNames updates related marker names.
func (m *Subject) UpdateMarkerNames() error { func (m *Subject) UpdateMarkerNames() error {
if m.SubjectName == "" { if m.SubjName == "" {
return fmt.Errorf("subject name is empty") return fmt.Errorf("subject name is empty")
} else if m.SubjectUID == "" { } else if m.SubjUID == "" {
return fmt.Errorf("subject uid is empty") return fmt.Errorf("subject uid is empty")
} }
if err := Db().Model(&Marker{}). if err := Db().Model(&Marker{}).
Where("subject_uid = ? AND subject_src <> ?", m.SubjectUID, SrcAuto). Where("subj_uid = ? AND subj_src <> ?", m.SubjUID, SrcAuto).
Where("marker_name <> ?", m.SubjectName). Where("marker_name <> ?", m.SubjName).
Update(Values{"MarkerName": m.SubjectName}).Error; err != nil { Update(Values{"MarkerName": m.SubjName}).Error; err != nil {
return err return err
} }
@@ -284,33 +291,33 @@ func (m *Subject) UpdateMarkerNames() error {
// RefreshPhotos flags related photos for metadata maintenance. // RefreshPhotos flags related photos for metadata maintenance.
func (m *Subject) RefreshPhotos() error { func (m *Subject) RefreshPhotos() error {
if m.SubjectUID == "" { if m.SubjUID == "" {
return fmt.Errorf("empty subject uid") return fmt.Errorf("empty subject uid")
} }
return UnscopedDb().Exec(`UPDATE photos SET checked_at = NULL WHERE id IN 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)`, (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.SubjectUID).Error gorm.Expr(Marker{}.TableName()), m.SubjUID).Error
} }
// MergeWith merges this subject with another subject and then deletes it. // MergeWith merges this subject with another subject and then deletes it.
func (m *Subject) MergeWith(other *Subject) error { func (m *Subject) MergeWith(other *Subject) error {
if other == nil { if other == nil {
return fmt.Errorf("other subject is 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") return fmt.Errorf("other subject's uid is empty")
} else if m.SubjectUID == "" { } else if m.SubjUID == "" {
return fmt.Errorf("subject uid is empty") 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{}). if err := Db().Model(&Marker{}).
Where("subject_uid = ?", m.SubjectUID). Where("subj_uid = ?", m.SubjUID).
Update(Values{"SubjectUID": other.SubjectUID}).Error; err != nil { Update(Values{"SubjUID": other.SubjUID}).Error; err != nil {
return err return err
} else if err := Db().Model(&Face{}). } else if err := Db().Model(&Face{}).
Where("subject_uid = ?", m.SubjectUID). Where("subj_uid = ?", m.SubjUID).
Update(Values{"SubjectUID": other.SubjectUID}).Error; err != nil { Update(Values{"SubjUID": other.SubjUID}).Error; err != nil {
return err return err
} else if err := other.UpdateMarkerNames(); err != nil { } else if err := other.UpdateMarkerNames(); err != nil {
return err return err
@@ -321,5 +328,5 @@ func (m *Subject) MergeWith(other *Subject) error {
// Links returns all share links for this entity. // Links returns all share links for this entity.
func (m *Subject) Links() Links { 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{ var SubjectFixtures = SubjectMap{
"john-doe": Subject{ "john-doe": Subject{
SubjectUID: "jqu0xs11qekk9jx8", SubjUID: "jqu0xs11qekk9jx8",
SubjectSlug: "john-doe", SubjSlug: "john-doe",
SubjectName: "John Doe", SubjName: "John Doe",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcManual, SubjSrc: SrcManual,
Favorite: true, SubjFavorite: true,
Private: false, SubjPrivate: false,
Excluded: false, SubjExcluded: false,
SubjectBio: "Subject Description", SubjBio: "Subject Description",
SubjectNotes: "Short Note", SubjNotes: "Short Note",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
FileCount: 1, FileCount: 1,
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
@@ -37,16 +37,16 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil, DeletedAt: nil,
}, },
"joe-biden": Subject{ "joe-biden": Subject{
SubjectUID: "jqy3y652h8njw0sx", SubjUID: "jqy3y652h8njw0sx",
SubjectSlug: "joe-biden", SubjSlug: "joe-biden",
SubjectName: "Joe Biden", SubjName: "Joe Biden",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcMarker, SubjSrc: SrcMarker,
Favorite: false, SubjFavorite: false,
Private: false, SubjPrivate: false,
Excluded: false, SubjExcluded: false,
SubjectBio: "", SubjBio: "",
SubjectNotes: "", SubjNotes: "",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
FileCount: 1, FileCount: 1,
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
@@ -54,17 +54,17 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil, DeletedAt: nil,
}, },
"dangling": Subject{ "dangling": Subject{
SubjectUID: "jqy1y111h1njaaaa", SubjUID: "jqy1y111h1njaaaa",
SubjectSlug: "dangling-subject", SubjSlug: "dangling-subject",
SubjectName: "Dangling Subject", SubjName: "Dangling Subject",
SubjectAlias: "Powell", SubjAlias: "Powell",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcMarker, SubjSrc: SrcMarker,
Favorite: false, SubjFavorite: false,
Private: false, SubjPrivate: false,
Excluded: false, SubjExcluded: false,
SubjectBio: "", SubjBio: "",
SubjectNotes: "", SubjNotes: "",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
FileCount: 0, FileCount: 0,
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
@@ -72,16 +72,16 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil, DeletedAt: nil,
}, },
"jane-doe": Subject{ "jane-doe": Subject{
SubjectUID: "jqy1y111h1njaaab", SubjUID: "jqy1y111h1njaaab",
SubjectSlug: "jane-doe", SubjSlug: "jane-doe",
SubjectName: "Jane Doe", SubjName: "Jane Doe",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcMarker, SubjSrc: SrcMarker,
Favorite: false, SubjFavorite: false,
Private: false, SubjPrivate: false,
Excluded: false, SubjExcluded: false,
SubjectBio: "", SubjBio: "",
SubjectNotes: "", SubjNotes: "",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
FileCount: 3, FileCount: 3,
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
@@ -89,28 +89,28 @@ var SubjectFixtures = SubjectMap{
DeletedAt: nil, DeletedAt: nil,
}, },
"actress-1": Subject{ "actress-1": Subject{
SubjectUID: "jqy1y111h1njaaac", SubjUID: "jqy1y111h1njaaac",
SubjectSlug: "actress-a", SubjSlug: "actress-a",
SubjectName: "Actress A", SubjName: "Actress A",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcMarker, SubjSrc: SrcMarker,
Favorite: false, SubjFavorite: false,
Private: false, SubjPrivate: false,
SubjectNotes: "", SubjNotes: "",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
UpdatedAt: TimeStamp(), UpdatedAt: TimeStamp(),
DeletedAt: nil, DeletedAt: nil,
}, },
"actor-1": Subject{ "actor-1": Subject{
SubjectUID: "jqy1y111h1njaaad", SubjUID: "jqy1y111h1njaaad",
SubjectSlug: "actor-a", SubjSlug: "actor-a",
SubjectName: "Actor A", SubjName: "Actor A",
SubjectType: SubjectPerson, SubjType: SubjPerson,
SubjectSrc: SrcMarker, SubjSrc: SrcMarker,
Favorite: false, SubjFavorite: false,
Private: false, SubjPrivate: false,
SubjectNotes: "", SubjNotes: "",
MetadataJSON: []byte(""), MetadataJSON: []byte(""),
CreatedAt: TimeStamp(), CreatedAt: TimeStamp(),
UpdatedAt: TimeStamp(), UpdatedAt: TimeStamp(),

View File

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

View File

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

View File

@@ -9,19 +9,19 @@ import (
func TestNewPerson(t *testing.T) { func TestNewPerson(t *testing.T) {
t.Run("BillGates", func(t *testing.T) { t.Run("BillGates", func(t *testing.T) {
subj := Subject{ subj := Subject{
SubjectUID: "jqytw12v8jjeu3e6", SubjUID: "jqytw12v8jjeu3e6",
SubjectName: "William Henry Gates III", SubjName: "William Henry Gates III",
SubjectAlias: "Windows Guru", SubjAlias: "Windows Guru",
Favorite: true, SubjFavorite: true,
Thumb: "622c7287967f2800e873fbc55f0328973056ce1d", Thumb: "622c7287967f2800e873fbc55f0328973056ce1d",
} }
m := NewPerson(subj) m := NewPerson(subj)
assert.Equal(t, "jqytw12v8jjeu3e6", m.SubjectUID) assert.Equal(t, "jqytw12v8jjeu3e6", m.SubjUID)
assert.Equal(t, "William Henry Gates III", m.SubjectName) assert.Equal(t, "William Henry Gates III", m.SubjName)
assert.Equal(t, "Windows Guru", m.SubjectAlias) assert.Equal(t, "Windows Guru", m.SubjAlias)
assert.Equal(t, true, m.Favorite) assert.Equal(t, true, m.SubjFavorite)
assert.Equal(t, "622c7287967f2800e873fbc55f0328973056ce1d", m.Thumb) assert.Equal(t, "622c7287967f2800e873fbc55f0328973056ce1d", m.Thumb)
if j, err := m.MarshalJSON(); err != nil { if j, err := m.MarshalJSON(); err != nil {

View File

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

View File

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

View File

@@ -3,11 +3,12 @@ package form
import "strings" import "strings"
type Selection struct { type Selection struct {
Files []string `json:"files"` Files []string `json:"files"`
Photos []string `json:"photos"` Photos []string `json:"photos"`
Albums []string `json:"albums"` Albums []string `json:"albums"`
Labels []string `json:"labels"` Labels []string `json:"labels"`
Places []string `json:"places"` Places []string `json:"places"`
Subjects []string `json:"subjects"`
} }
func (f Selection) Empty() bool { func (f Selection) Empty() bool {
@@ -22,6 +23,8 @@ func (f Selection) Empty() bool {
return false return false
case len(f.Places) > 0: case len(f.Places) > 0:
return false return false
case len(f.Subjects) > 0:
return false
} }
return true return true
@@ -36,6 +39,7 @@ func (f Selection) All() []string {
all = append(all, f.Albums...) all = append(all, f.Albums...)
all = append(all, f.Labels...) all = append(all, f.Labels...)
all = append(all, f.Places...) all = append(all, f.Places...)
all = append(all, f.Subjects...)
return all 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"}} sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}, Files: []string{}, Places: []string{"foo", "bar"}}
assert.Equal(t, false, sel.Empty()) 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) { t.Run("empty", func(t *testing.T) {
sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}} sel := Selection{Photos: []string{}, Albums: []string{}, Labels: []string{}}
assert.Equal(t, true, sel.Empty()) assert.Equal(t, true, sel.Empty())
@@ -35,8 +40,8 @@ func TestSelection_Empty(t *testing.T) {
func TestSelection_All(t *testing.T) { func TestSelection_All(t *testing.T) {
t.Run("success", func(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"}} 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"}, sel.All()) 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 MsgCopyingFilesFrom
MsgLabelsDeleted MsgLabelsDeleted
MsgLabelSaved MsgLabelSaved
MsgSubjectSaved
MsgSubjectDeleted
MsgFilesUploadedIn MsgFilesUploadedIn
MsgSelectionApproved MsgSelectionApproved
MsgSelectionArchived MsgSelectionArchived
@@ -132,6 +134,8 @@ var Messages = MessageMap{
MsgCopyingFilesFrom: gettext("Copying files from %s"), MsgCopyingFilesFrom: gettext("Copying files from %s"),
MsgLabelsDeleted: gettext("Labels deleted"), MsgLabelsDeleted: gettext("Labels deleted"),
MsgLabelSaved: gettext("Label saved"), MsgLabelSaved: gettext("Label saved"),
MsgSubjectSaved: gettext("Subject saved"),
MsgSubjectDeleted: gettext("Subject deleted"),
MsgFilesUploadedIn: gettext("%d files uploaded in %d s"), MsgFilesUploadedIn: gettext("%d files uploaded in %d s"),
MsgSelectionApproved: gettext("Selection approved"), MsgSelectionApproved: gettext("Selection approved"),
MsgSelectionArchived: gettext("Selection archived"), MsgSelectionArchived: gettext("Selection archived"),

View File

@@ -65,7 +65,7 @@ func (w *Faces) Audit(fix bool) (err error) {
for _, f2 := range faces { for _, f2 := range faces {
if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched { if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched {
if f1.SubjectUID == f2.SubjectUID { if f1.SubjUID == f2.SubjUID {
continue 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) 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 != "" { if f1.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f1.ID, txt.Quote(subj[f1.SubjectUID].SubjectName), f1.SubjectUID, entity.SrcString(f1.FaceSrc)) log.Infof("face %s: subject %s (%s %s)", f1.ID, txt.Quote(subj[f1.SubjUID].SubjName), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else { } else {
log.Infof("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc)) log.Infof("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
} }
if f2.SubjectUID != "" { if f2.SubjUID != "" {
log.Infof("face %s: subject %s (%s %s)", f2.ID, txt.Quote(subj[f2.SubjectUID].SubjectName), f2.SubjectUID, entity.SrcString(f2.FaceSrc)) log.Infof("face %s: subject %s (%s %s)", f2.ID, txt.Quote(subj[f2.SubjUID].SubjName), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else { } else {
log.Infof("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc)) 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) log.Error(err)
} else { } else {
for _, m := range markers { 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++ result.Updated++
} }
if marker.SubjectUID != "" { if marker.SubjUID != "" {
result.Recognized++ result.Recognized++
} else { } else {
result.Unknown++ result.Unknown++

View File

@@ -37,7 +37,7 @@ func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
for j := 0; j <= n; j++ { for j := 0; j <= n; j++ {
if len(merge) == 0 { if len(merge) == 0 {
merge = entity.Faces{faces[j]} 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 { if len(merge) < 2 {
// Nothing to merge. // Nothing to merge.
} else if _, err := query.MergeFaces(merge); err != nil { } else if _, err := query.MergeFaces(merge); err != nil {
@@ -48,7 +48,7 @@ func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
merge = nil merge = nil
} else if ok, dist := merge[0].Match(entity.Embeddings{faces[j].Embedding()}); ok { } 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]) merge = append(merge, faces[j])
} else if len(merge) == 1 { } else if len(merge) == 1 {
merge = nil merge = nil

View File

@@ -69,7 +69,7 @@ func (w *Faces) Stats() (err error) {
min := -1.0 min := -1.0
max := -1.0 max := -1.0
if k, ok := dist[f1.SubjectUID]; ok { if k, ok := dist[f1.SubjUID]; ok {
min = k[0] min = k[0]
max = k[1] max = k[1]
} }
@@ -81,7 +81,7 @@ func (w *Faces) Stats() (err error) {
f2 := faces[j] f2 := faces[j]
if f1.SubjectUID != f2.SubjectUID { if f1.SubjUID != f2.SubjUID {
continue continue
} }
@@ -99,7 +99,7 @@ func (w *Faces) Stats() (err error) {
} }
if max > 0 { 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 { 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 return result, err
} }
@@ -31,7 +31,7 @@ func Faces(knownOnly, unmatched bool) (result entity.Faces, err error) {
func ManuallyAddedFaces() (result entity.Faces, err error) { func ManuallyAddedFaces() (result entity.Faces, err error) {
err = Db(). err = Db().
Where("face_src = ?", entity.SrcManual). Where("face_src = ?", entity.SrcManual).
Where("subject_uid <> ''").Order("subject_uid, samples DESC"). Where("subj_uid <> ''").Order("subj_uid, samples DESC").
Find(&result).Error Find(&result).Error
return result, err return result, err
@@ -48,9 +48,9 @@ func MatchFaceMarkers() (affected int64, err error) {
for _, f := range faces { for _, f := range faces {
if res := Db().Model(&entity.Marker{}). if res := Db().Model(&entity.Marker{}).
Where("face_id = ?", f.ID). Where("face_id = ?", f.ID).
Where("subject_src = ?", entity.SrcAuto). Where("subj_src = ?", entity.SrcAuto).
Where("subject_uid <> ?", f.SubjectUID). Where("subj_uid <> ?", f.SubjUID).
Updates(entity.Values{"SubjectUID": f.SubjectUID, "Review": false}); res.Error != nil { Updates(entity.Values{"SubjUID": f.SubjUID, "MarkerReview": false}); res.Error != nil {
return affected, err return affected, err
} else if res.RowsAffected > 0 { } else if res.RowsAffected > 0 {
affected += res.RowsAffected affected += res.RowsAffected
@@ -64,7 +64,7 @@ func MatchFaceMarkers() (affected int64, err error) {
func RemoveAnonymousFaceClusters() (removed int64, err error) { func RemoveAnonymousFaceClusters() (removed int64, err error) {
res := UnscopedDb().Delete( res := UnscopedDb().Delete(
entity.Face{}, entity.Face{},
"face_src = ? AND subject_uid = ''", entity.SrcAuto) "face_src = ? AND subj_uid = ''", entity.SrcAuto)
return res.RowsAffected, res.Error 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") 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++ { 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", 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. // Find or create merged face cluster.
if merged = entity.NewFace(merge[0].SubjectUID, merge[0].FaceSrc, merge.Embeddings()); merged == nil { 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(subjectUID)) return merged, fmt.Errorf("faces: new cluster is nil for subject %s", txt.Quote(subjUID))
} else if merged = entity.FirstOrCreateFace(merged); merged == nil { } 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 { } else if err := merged.MatchMarkers(append(merge.IDs(), "")); err != nil {
return merged, err 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 { if removed, err := PurgeOrphanFaces(merge.IDs()); err != nil {
return merged, err return merged, err
} else if removed > 0 { } 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 { } 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 return merged, err
@@ -172,7 +172,7 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) {
for _, f1 := range faces { for _, f1 := range faces {
for _, f2 := range faces { for _, f2 := range faces {
if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched { if matched, dist := f1.Match(entity.Embeddings{f2.Embedding()}); matched {
if f1.SubjectUID == f2.SubjectUID { if f1.SubjUID == f2.SubjUID {
continue 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) 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 != "" { if f1.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f1.ID, txt.Quote(f1.SubjectUID), f1.SubjectUID, entity.SrcString(f1.FaceSrc)) log.Debugf("face %s: subject %s (%s %s)", f1.ID, txt.Quote(f1.SubjUID), f1.SubjUID, entity.SrcString(f1.FaceSrc))
} else { } else {
log.Debugf("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc)) log.Debugf("face %s: no subject (%s)", f1.ID, entity.SrcString(f1.FaceSrc))
} }
if f2.SubjectUID != "" { if f2.SubjUID != "" {
log.Debugf("face %s: subject %s (%s %s)", f2.ID, txt.Quote(f2.SubjectUID), f2.SubjectUID, entity.SrcString(f2.FaceSrc)) log.Debugf("face %s: subject %s (%s %s)", f2.ID, txt.Quote(f2.SubjUID), f2.SubjUID, entity.SrcString(f2.FaceSrc))
} else { } else {
log.Debugf("face %s: no subject (%s)", f2.ID, entity.SrcString(f2.FaceSrc)) 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 { } else if m == nil {
t.Fatal("marker is nil") t.Fatal("marker is nil")
} else { } else {
assert.Empty(t, m.SubjectUID) assert.Empty(t, m.SubjUID)
} }
// Reset subject_uid. // Reset subj_uid.
if err := Db().Model(&entity.Marker{}). if err := Db().Model(&entity.Marker{}).
Where("subject_src = ?", entity.SrcAuto). Where("subj_src = ?", entity.SrcAuto).
Where("subject_uid = ?", "jqu0xs11qekk9jx8"). Where("subj_uid = ?", "jqu0xs11qekk9jx8").
Updates(entity.Values{"SubjectUID": ""}).Error; err != nil { Updates(entity.Values{"SubjUID": ""}).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -86,7 +86,7 @@ func TestMatchFaceMarkers(t *testing.T) {
} else if m == nil { } else if m == nil {
t.Fatal("marker is nil") t.Fatal("marker is nil")
} else { } 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, "5LH5E35ZGUMF5AYLM42BIZH4DGQHJDAV", result.ID)
assert.Equal(t, entity.SrcManual, result.FaceSrc) 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, 2, result.Samples)
assert.Equal(t, 0.03948165743305488, result.SampleRadius) assert.Equal(t, 0.03948165743305488, result.SampleRadius)
assert.Equal(t, 0, result.Collisions) 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. // Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" { 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) f.Subject = strings.Join(subj, And)
log.Debugf("search: subject %s", txt.Quote(strings.Join(names, ", "))) log.Debugf("search: subject %s", txt.Quote(strings.Join(names, ", ")))
f.Query = remaining f.Query = remaining
@@ -115,12 +115,12 @@ func Geo(f form.GeoSearch) (results GeoResults, err error) {
// Filter for one or more subjects? // Filter for one or more subjects?
if f.Subject != "" { if f.Subject != "" {
for _, subj := range strings.Split(strings.ToLower(f.Subject), And) { 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)) entity.Marker{}.TableName()), strings.Split(subj, Or))
} }
} else if f.Subjects != "" { } else if f.Subjects != "" {
for _, where := range LikeAnyWord("s.subject_name", f.Subjects) { 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.subject_uid = m.subject_uid WHERE (?))", 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)) 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 { if subjects {
db = db.Where("subject_uid <> ''") db = db.Where("subj_uid <> ''")
} }
if !matchedBefore.IsZero() { if !matchedBefore.IsZero() {
@@ -119,8 +119,8 @@ func Embeddings(single, unclustered bool, size, score int) (result entity.Embedd
func RemoveInvalidMarkerReferences() (removed int64, err error) { func RemoveInvalidMarkerReferences() (removed int64, err error) {
res := Db(). res := Db().
Model(&entity.Marker{}). Model(&entity.Marker{}).
Where("marker_invalid = 1 AND (subject_uid <> '' OR face_id <> '')"). Where("marker_invalid = 1 AND (subj_uid <> '' OR face_id <> '')").
UpdateColumns(entity.Values{"subject_uid": "", "face_id": "", "face_dist": -1.0, "matched_at": nil}) UpdateColumns(entity.Values{"subj_uid": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
return res.RowsAffected, res.Error return res.RowsAffected, res.Error
} }
@@ -141,8 +141,8 @@ func RemoveNonExistentMarkerFaces() (removed int64, err error) {
func RemoveNonExistentMarkerSubjects() (removed int64, err error) { func RemoveNonExistentMarkerSubjects() (removed int64, err error) {
res := Db(). res := Db().
Model(&entity.Marker{}). Model(&entity.Marker{}).
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())).
UpdateColumns(entity.Values{"subject_uid": "", "matched_at": nil}) UpdateColumns(entity.Values{"subj_uid": "", "matched_at": nil})
return res.RowsAffected, res.Error return res.RowsAffected, res.Error
} }
@@ -182,7 +182,7 @@ func MarkersWithNonExistentReferences() (faces entity.Markers, subjects entity.M
// Find markers with invalid subject UIDs. // Find markers with invalid subject UIDs.
if res := Db(). 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 { Find(&subjects); res.Error != nil {
err = res.Error err = res.Error
} }
@@ -193,7 +193,7 @@ func MarkersWithNonExistentReferences() (faces entity.Markers, subjects entity.M
// MarkersWithSubjectConflict finds markers with conflicting subjects. // MarkersWithSubjectConflict finds markers with conflicting subjects.
func MarkersWithSubjectConflict() (results entity.Markers, err error) { func MarkersWithSubjectConflict() (results entity.Markers, err error) {
err = Db(). 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"). Order("face_id").
Find(&results).Error 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. // ResetFaceMarkerMatches removes automatically added subject and face references from the markers table.
func ResetFaceMarkerMatches() (removed int64, err error) { func ResetFaceMarkerMatches() (removed int64, err error) {
res := Db().Model(&entity.Marker{}). res := Db().Model(&entity.Marker{}).
Where("subject_src = ? AND marker_type = ?", entity.SrcAuto, entity.MarkerFace). Where("subj_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}) UpdateColumns(entity.Values{"marker_name": "", "subj_uid": "", "subj_src": "", "face_id": "", "face_dist": -1.0, "matched_at": nil})
return res.RowsAffected, res.Error 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. // Modify query if it contains subject names.
if f.Query != "" && f.Subject == "" { 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) f.Subject = strings.Join(subj, And)
log.Debugf("people: searching for %s", txt.Quote(txt.JoinNames(names))) log.Debugf("people: searching for %s", txt.Quote(txt.JoinNames(names)))
f.Query = remaining f.Query = remaining
@@ -222,12 +222,12 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
// Filter for one or more subjects? // Filter for one or more subjects?
if f.Subject != "" { if f.Subject != "" {
for _, subj := range strings.Split(strings.ToLower(f.Subject), And) { 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)) entity.Marker{}.TableName()), strings.Split(subj, Or))
} }
} else if f.Subjects != "" { } else if f.Subjects != "" {
for _, where := range LikeAnyWord("s.subject_name", f.Subjects) { 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.subject_uid = m.subject_uid WHERE (?))", 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)) 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()). return Db().Table(entity.Subject{}.TableName()).
UpdateColumn("thumb", gorm.Expr("(SELECT f.file_hash FROM files f "+ UpdateColumn("thumb", gorm.Expr("(SELECT f.file_hash FROM files f "+
fmt.Sprintf( 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.Marker{}.TableName(),
entity.Subject{}.TableName())+ entity.Subject{}.TableName())+
` JOIN photos p ON f.photo_id = p.id ` JOIN photos p ON f.photo_id = p.id
@@ -172,7 +172,7 @@ func UpdateSubjectPreviews() (err error) {
err = Db().Table(entity.Subject{}.TableName()). err = Db().Table(entity.Subject{}.TableName()).
UpdateColumn("thumb", gorm.Expr("(SELECT m.file_hash FROM "+ UpdateColumn("thumb", gorm.Expr("(SELECT m.file_hash FROM "+
fmt.Sprintf( 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.Marker{}.TableName(),
entity.Subject{}.TableName())+ entity.Subject{}.TableName())+
` AND m.file_hash <> '' ORDER BY m.size DESC LIMIT 1) ` 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 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 (?)) 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.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 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 (?))`, 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"). s := UnscopedDb().Table("photos").
Select("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 { if result := s.Scan(&results); result.Error != nil {
return results, result.Error 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 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 (?)) 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.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 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 (?))`, 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"). s := UnscopedDb().Table("files").
Select("files.*"). Select("files.*").
Joins("JOIN photos ON photos.id = files.photo_id"). Joins("JOIN photos ON photos.id = files.photo_id").
Where("photos.deleted_at IS NULL"). Where("photos.deleted_at IS NULL").
Where("files.file_missing = 0"). 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") Group("files.id")
if result := s.Scan(&results); result.Error != nil { if result := s.Scan(&results); result.Error != nil {

View File

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

View File

@@ -12,7 +12,7 @@ import (
func TestSubjectSearch(t *testing.T) { func TestSubjectSearch(t *testing.T) {
t.Run("FindAll", func(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.NoError(t, err)
assert.LessOrEqual(t, 3, len(results)) assert.LessOrEqual(t, 3, len(results))
}) })

View File

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

View File

@@ -72,9 +72,9 @@ func TestCreateMarkerSubjects(t *testing.T) {
assert.LessOrEqual(t, int64(0), affected) 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) { 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 { if len(result) != 1 {
t.Fatal("expected one result") t.Fatal("expected one result")
@@ -85,11 +85,11 @@ func TestSearchSubjectUIDs(t *testing.T) {
} }
}) })
t.Run("xxx", func(t *testing.T) { t.Run("xxx", func(t *testing.T) {
result, _, _ := SearchSubjectUIDs("xxx") result, _, _ := SearchSubjUIDs("xxx")
assert.Empty(t, result) assert.Empty(t, result)
}) })
t.Run("empty string", func(t *testing.T) { t.Run("empty string", func(t *testing.T) {
result, _, _ := SearchSubjectUIDs("") result, _, _ := SearchSubjUIDs("")
assert.Empty(t, result) assert.Empty(t, result)
}) })
} }

View File

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