Proof-of-concept for labels and search filters
This commit is contained in:
parent
7eccd2a440
commit
8642b6f664
1
Makefile
1
Makefile
|
@ -46,6 +46,7 @@ zip-nasnet:
|
|||
build-js:
|
||||
(cd frontend && env NODE_ENV=production npm run build)
|
||||
build-go:
|
||||
rm -f $(BINARY_NAME)
|
||||
scripts/build.sh debug $(BINARY_NAME)
|
||||
watch-js:
|
||||
(cd frontend && env NODE_ENV=development npm run watch)
|
||||
|
|
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
|
@ -10906,10 +10906,10 @@
|
|||
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz",
|
||||
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
|
||||
},
|
||||
"vue-truncate-filter": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vue-truncate-filter/-/vue-truncate-filter-1.1.7.tgz",
|
||||
"integrity": "sha512-kFmUDsDFIj5vArdK6hX84v80fTWJIMrsYF5keCluy9hMiaKGL8NyApnMbWavZeDod24YeYMVQ9jjd7MkRn3AAg=="
|
||||
"vue2-filters": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/vue2-filters/-/vue2-filters-0.6.0.tgz",
|
||||
"integrity": "sha512-l7GPoQW0aWBS0RBP5dI1udAlg8qcfd8zOHqFrQ29ZOKfAaLxGMyqsqTo1thvfaAs6w2egLPlllsaqBhmHzaV6w=="
|
||||
},
|
||||
"vue2-leaflet": {
|
||||
"version": "2.1.1",
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
"vue-router": "^3.0.6",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
"vue-template-compiler": "^2.6.10",
|
||||
"vue-truncate-filter": "^1.1.7",
|
||||
"vue2-filters": "^0.6.0",
|
||||
"vue2-leaflet": "^2.1.1",
|
||||
"vuelidate": "^0.7.4",
|
||||
"vuetify": "^1.5.14",
|
||||
|
|
|
@ -12,10 +12,10 @@ import Alert from "common/alert";
|
|||
import Viewer from "common/viewer";
|
||||
import Session from "common/session";
|
||||
import Event from "pubsub-js";
|
||||
import Moment from "vue-moment";
|
||||
import InfiniteScroll from "vue-infinite-scroll";
|
||||
import VueTruncate from "vue-truncate-filter";
|
||||
import VueMoment from "vue-moment";
|
||||
import VueInfiniteScroll from "vue-infinite-scroll";
|
||||
import VueFullscreen from "vue-fullscreen";
|
||||
import VueFilters from "vue2-filters";
|
||||
|
||||
// Initialize helpers
|
||||
const session = new Session(window.localStorage);
|
||||
|
@ -48,10 +48,10 @@ Vue.use(Vuetify, {
|
|||
});
|
||||
|
||||
// Register other VueJS plugins
|
||||
Vue.use(Moment);
|
||||
Vue.use(InfiniteScroll);
|
||||
Vue.use(VueTruncate);
|
||||
Vue.use(VueMoment);
|
||||
Vue.use(VueInfiniteScroll);
|
||||
Vue.use(VueFullscreen);
|
||||
Vue.use(VueFilters);
|
||||
Vue.use(Components);
|
||||
Vue.use(Maps);
|
||||
Vue.use(Router);
|
||||
|
|
|
@ -85,13 +85,23 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile to="/calendar" @click="" class="p-navigation-timeline">
|
||||
<v-list-tile to="/events" @click="" class="p-navigation-events">
|
||||
<v-list-tile-action>
|
||||
<v-icon>date_range</v-icon>
|
||||
</v-list-tile-action>
|
||||
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Timeline</v-list-tile-title>
|
||||
<v-list-tile-title>Events</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile to="/people" @click="" class="p-navigation-people">
|
||||
<v-list-tile-action>
|
||||
<v-icon>people</v-icon>
|
||||
</v-list-tile-action>
|
||||
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>People</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
|
@ -112,18 +122,47 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile @click="">
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'lat:52.459714999999996 long:13.321887700000001 dist:20' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Canon EOS 6D</v-list-tile-title>
|
||||
<v-list-tile-title>Berlin</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile @click="">
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'mono:true' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>iPhone</v-list-tile-title>
|
||||
<v-list-tile-title>Monochrome</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'label:cat' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Cats</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'label:computer' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Computers</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'color:magenta' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Magenta</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'color:red' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Red</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile :to="{ name: 'Photos', query: { q: 'chroma:4' }}" :exact="true" @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Vibrant</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-tile v-if="mini" to="/albums" @click="">
|
||||
|
@ -145,25 +184,9 @@
|
|||
|
||||
<v-list-tile @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>South Africa</v-list-tile-title>
|
||||
<v-list-tile-title>Not implemented yet</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Cats & Dogs</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile @click="">
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title>Create album</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
|
||||
<v-list-tile-action>
|
||||
<v-icon>add</v-icon>
|
||||
</v-list-tile-action>
|
||||
</v-list-tile>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-tile to="/import" @click="">
|
||||
|
@ -204,9 +227,11 @@
|
|||
export default {
|
||||
name: "p-navigation",
|
||||
data() {
|
||||
let mini = (window.innerWidth < 1600);
|
||||
|
||||
return {
|
||||
drawer: null,
|
||||
mini: false,
|
||||
mini: mini,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<v-form ref="form" class="p-photo-search" lazy-validation @submit="filterChange" dense>
|
||||
<v-form ref="form" class="p-photo-search" lazy-validation @submit.prevent="filterChange" dense>
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<v-text-field class="pt-3 pr-3"
|
||||
single-line
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
@import url("maps.css");
|
||||
@import url("viewer.css");
|
||||
@import url("photos.css");
|
||||
@import url("labels.css");
|
||||
|
||||
body {
|
||||
background: rgb(250, 250, 250);
|
||||
|
|
4
frontend/src/css/labels.css
Normal file
4
frontend/src/css/labels.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
#photoprism .p-labels-details .p-label-like
|
||||
{
|
||||
left: 4px; bottom: 4px;
|
||||
}
|
79
frontend/src/model/label.js
Normal file
79
frontend/src/model/label.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
import Abstract from "model/abstract";
|
||||
import Api from "common/api";
|
||||
import moment from "moment";
|
||||
|
||||
class Label extends Abstract {
|
||||
getEntityName() {
|
||||
return this.LabelSlug;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.LabelSlug;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.LabelName;
|
||||
}
|
||||
|
||||
getThumbnailUrl(type) {
|
||||
return "/api/v1/labels/" + this.getId() + "/thumbnail/" + type;
|
||||
}
|
||||
|
||||
getThumbnailSrcset() {
|
||||
const result = [];
|
||||
|
||||
result.push(this.getThumbnailUrl("fit_720") + " 720w");
|
||||
result.push(this.getThumbnailUrl("fit_1280") + " 1280w");
|
||||
result.push(this.getThumbnailUrl("fit_1920") + " 1920w");
|
||||
result.push(this.getThumbnailUrl("fit_2560") + " 2560w");
|
||||
result.push(this.getThumbnailUrl("fit_3840") + " 3840w");
|
||||
|
||||
return result.join(", ");
|
||||
}
|
||||
|
||||
getThumbnailSizes() {
|
||||
const result = [];
|
||||
|
||||
result.push("(min-width: 2560px) 3840px");
|
||||
result.push("(min-width: 1920px) 2560px");
|
||||
result.push("(min-width: 1280px) 1920px");
|
||||
result.push("(min-width: 720px) 1280px");
|
||||
result.push("720px");
|
||||
|
||||
return result.join(", ");
|
||||
}
|
||||
|
||||
getDateString() {
|
||||
return moment(this.CreatedAt).format('LLL');
|
||||
}
|
||||
|
||||
toggleLike() {
|
||||
this.LabelFavorite = !this.LabelFavorite;
|
||||
|
||||
if(this.LabelFavorite) {
|
||||
return Api.post(this.getEntityResource() + "/like");
|
||||
} else {
|
||||
return Api.delete(this.getEntityResource() + "/like");
|
||||
}
|
||||
}
|
||||
|
||||
like() {
|
||||
this.LabelFavorite = true;
|
||||
return Api.post(this.getEntityResource() + "/like");
|
||||
}
|
||||
|
||||
unlike() {
|
||||
this.LabelFavorite = false;
|
||||
return Api.delete(this.getEntityResource() + "/like");
|
||||
}
|
||||
|
||||
static getCollectionResource() {
|
||||
return "labels";
|
||||
}
|
||||
|
||||
static getModelName() {
|
||||
return "Label";
|
||||
}
|
||||
}
|
||||
|
||||
export default Label;
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<v-toolbar-title>Calendar</v-toolbar-title>
|
||||
<v-toolbar-title>Events</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
</v-toolbar>
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="loadMoreDisabled" infinite-scroll-distance="10">
|
||||
<v-form ref="form" lazy-validation @submit="formChange" dense>
|
||||
<div class="p-page p-page-labels" v-infinite-scroll="loadMore" :infinite-scroll-disabled="scrollDisabled"
|
||||
:infinite-scroll-distance="10" :infinite-scroll-listen-for-event="'scrollRefresh'">
|
||||
|
||||
<v-form ref="form" class="p-labels-search" lazy-validation @submit.prevent="search" dense>
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<h1 class="md-display-1">Labels</h1>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field class="pt-3 pr-3"
|
||||
single-line
|
||||
label="Search"
|
||||
|
@ -11,407 +11,124 @@
|
|||
clearable
|
||||
color="blue-grey"
|
||||
@click:clear="clearQuery"
|
||||
v-model="query.q"
|
||||
@keyup.enter.native="formChange"
|
||||
v-model="filter.q"
|
||||
@keyup.enter.native="search"
|
||||
id="search"
|
||||
></v-text-field>
|
||||
<!-- v-btn @click="formChange" color="secondary">Create Filter</v-btn -->
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn icon @click="advandedSearch = !advandedSearch">
|
||||
<v-icon>{{ advandedSearch ? 'keyboard_arrow_up' : 'keyboard_arrow_down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card class="pt-1"
|
||||
flat
|
||||
color="blue-grey lighten-5"
|
||||
v-show="advandedSearch">
|
||||
<v-card-text>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs12 sm6 md3 pa-2>
|
||||
<v-select @change="formChange"
|
||||
label="View"
|
||||
flat solo hide-details
|
||||
color="blue-grey"
|
||||
v-model="query.view"
|
||||
:items="options.views">
|
||||
</v-select>
|
||||
</v-flex>
|
||||
<v-flex xs12 sm6 md3 pa-2>
|
||||
<v-select @change="formChange"
|
||||
label="Sort By"
|
||||
flat solo hide-details
|
||||
color="blue-grey"
|
||||
v-model="query.order"
|
||||
:items="options.sorting">
|
||||
</v-select>
|
||||
</v-flex>
|
||||
<v-flex xs12 sm6 md3 pa-2>
|
||||
<v-select @change="formChange"
|
||||
label="Groups"
|
||||
flat solo hide-details
|
||||
color="blue-grey"
|
||||
v-model="query.group"
|
||||
:items="options.groups">
|
||||
</v-select>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-form>
|
||||
|
||||
<v-container fluid>
|
||||
<v-speed-dial
|
||||
fixed
|
||||
bottom
|
||||
right
|
||||
direction="top"
|
||||
open-on-hover
|
||||
transition="slide-y-reverse-transition"
|
||||
style="right: 8px; bottom: 8px;"
|
||||
>
|
||||
<v-btn
|
||||
slot="activator"
|
||||
color="grey darken-2"
|
||||
dark
|
||||
fab
|
||||
>
|
||||
<v-icon>menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="deep-purple lighten-2"
|
||||
>
|
||||
<v-icon>favorite</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="cyan accent-4"
|
||||
>
|
||||
<v-icon>youtube_searched_for</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="teal accent-4"
|
||||
>
|
||||
<v-icon>save</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
@click.stop="dialog2 = true"
|
||||
color="yellow accent-4"
|
||||
>
|
||||
<v-icon>create_new_folder</v-icon>
|
||||
</v-btn>
|
||||
<v-container grid-list-xs fluid class="pa-0 p-labels p-labels-details">
|
||||
<v-card v-if="results.length === 0" class="p-labels-empty">
|
||||
<v-card-title primary-title>
|
||||
<div>
|
||||
<h3 class="headline mb-3">No labels matched your search</h3>
|
||||
<div>Try again using other terms.</div>
|
||||
</div>
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
<v-layout row wrap>
|
||||
<v-flex
|
||||
v-for="(label, index) in results"
|
||||
:key="index"
|
||||
class="p-label"
|
||||
xs12 sm6 md4 lg3 d-flex
|
||||
>
|
||||
<v-hover>
|
||||
<v-card tile slot-scope="{ hover }"
|
||||
class="elevation-2 ma-2">
|
||||
<v-img
|
||||
:src="label.getThumbnailUrl('tile_500')"
|
||||
aspect-ratio="1"
|
||||
style="cursor: pointer"
|
||||
class="grey lighten-2"
|
||||
@click="openLabel(index)"
|
||||
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
small
|
||||
color="delete"
|
||||
>
|
||||
<v-icon>delete</v-icon>
|
||||
</v-btn>
|
||||
</v-speed-dial>
|
||||
>
|
||||
<v-layout
|
||||
slot="placeholder"
|
||||
fill-height
|
||||
align-center
|
||||
justify-center
|
||||
ma-0
|
||||
>
|
||||
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
|
||||
<v-data-table
|
||||
:headers="listColumns"
|
||||
:items="results"
|
||||
hide-actions
|
||||
class="elevation-1"
|
||||
v-if="query.view === 'list'"
|
||||
select-all
|
||||
disable-initial-sort
|
||||
item-key="ID"
|
||||
v-model="selected"
|
||||
:no-data-text="'No photos matched your search'"
|
||||
>
|
||||
<template slot="items" slot-scope="props">
|
||||
<td>
|
||||
<v-checkbox
|
||||
v-model="props.selected"
|
||||
primary
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
</td>
|
||||
<td>Label</td>
|
||||
<td>28/11/2019</td>
|
||||
<td>#4</td>
|
||||
<td>55</td>
|
||||
<td>
|
||||
<v-btn color="success"
|
||||
@click.stop="dialog = true">
|
||||
Edit
|
||||
</v-btn>
|
||||
</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
<v-container fluid v-if="query.view === 'cloud'">
|
||||
<v-layout justify-space-around>
|
||||
<v-flex>
|
||||
<v-img src="/static/img/tagcloud.jpg" aspect-ratio="1.7" @click.stop="dialog = true"></v-img>
|
||||
<v-btn v-if="hover || label.LabelFavorite" :flat="!hover" :ripple="false"
|
||||
icon large absolute
|
||||
class="p-label-like"
|
||||
@click.stop.prevent="label.toggleLike()">
|
||||
<v-icon v-if="label.LabelFavorite" color="white">favorite
|
||||
</v-icon>
|
||||
<v-icon v-else color="grey lighten-3">favorite_border</v-icon>
|
||||
</v-btn>
|
||||
</v-img>
|
||||
|
||||
<v-card-title primary-title class="pa-3">
|
||||
<div>
|
||||
<h3 class="subheading mb-2">{{ label.LabelName | capitalize }}</h3>
|
||||
</div>
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
|
||||
<v-snackbar
|
||||
v-model="snackbarVisible"
|
||||
bottom
|
||||
:timeout="0"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
<v-btn
|
||||
class="pr-0"
|
||||
color="primary"
|
||||
icon
|
||||
flat
|
||||
@click="clearSelection()"
|
||||
>
|
||||
<v-icon>close</v-icon>
|
||||
</v-btn>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
|
||||
<v-dialog v-model="dialog" dark persistent max-width="600px">
|
||||
<v-card dark>
|
||||
<v-card-title>
|
||||
<span class="headline">Edit tag - Cat</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<template>
|
||||
<form>
|
||||
<b>Translate:</b>
|
||||
<v-select
|
||||
v-model="select"
|
||||
:items="items"
|
||||
label="Language"
|
||||
></v-select>
|
||||
<v-text-field
|
||||
v-model="translation"
|
||||
label="Translation"
|
||||
></v-text-field>
|
||||
<v-spacer></v-spacer>
|
||||
<v-select
|
||||
v-model="select"
|
||||
:items="items"
|
||||
label="Language"
|
||||
></v-select>
|
||||
<v-text-field
|
||||
v-model="translation"
|
||||
label="Translation"
|
||||
></v-text-field>
|
||||
<v-spacer></v-spacer>
|
||||
<b>Add to group:</b>
|
||||
<v-select
|
||||
v-model="select"
|
||||
:items="items2"
|
||||
label="Select"
|
||||
></v-select>
|
||||
</form>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" flat @click="dialog = false">Cancel</v-btn>
|
||||
<v-btn color="success" flat @click="dialog = false">apply</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="dialog2" dark persistent max-width="600px">
|
||||
<v-card dark>
|
||||
<v-card-title>
|
||||
<span class="headline">Add tags to group</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<template>
|
||||
<form>
|
||||
13 tags selected <br>
|
||||
<v-spacer></v-spacer>
|
||||
<v-select
|
||||
v-model="select"
|
||||
:items="items2"
|
||||
label="Group"
|
||||
></v-select>
|
||||
</form>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" flat @click="dialog2 = false">Cancel</v-btn>
|
||||
<v-btn color="success" flat @click="dialog2 = false">apply</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Photo from 'model/photo';
|
||||
import Label from "model/label";
|
||||
|
||||
export default {
|
||||
name: 'labels',
|
||||
props: {},
|
||||
name: 'p-page-labels',
|
||||
props: {
|
||||
staticFilter: Object
|
||||
},
|
||||
watch: {
|
||||
'$route'() {
|
||||
const query = this.$route.query;
|
||||
|
||||
this.filter.q = query['q'];
|
||||
this.lastFilter = {};
|
||||
this.search();
|
||||
}
|
||||
},
|
||||
data() {
|
||||
const query = this.$route.query;
|
||||
const order = query['order'] ? query['order'] : 'newest';
|
||||
const camera = query['camera'] ? parseInt(query['camera']) : 0;
|
||||
const q = query['q'] ? query['q'] : '';
|
||||
const country = query['country'] ? query['country'] : '';
|
||||
const view = query['view'] ? query['view'] : 'cloud';
|
||||
const group = query['group'] ? query['group'] : '';
|
||||
const filter = {q: q};
|
||||
const settings = {};
|
||||
|
||||
return {
|
||||
'snackbarVisible': false,
|
||||
'snackbarText': '',
|
||||
'advandedSearch': false,
|
||||
'window': {
|
||||
width: 0,
|
||||
height: 0
|
||||
},
|
||||
'results': [],
|
||||
'query': {
|
||||
view: view,
|
||||
country: country,
|
||||
camera: camera,
|
||||
order: order,
|
||||
q: q,
|
||||
},
|
||||
'options': {
|
||||
'categories': [
|
||||
{value: '', text: 'All Categories'},
|
||||
{value: 'airport', text: 'Airport'},
|
||||
{value: 'amenity', text: 'Amenity'},
|
||||
{value: 'building', text: 'Building'},
|
||||
{value: 'historic', text: 'Historic'},
|
||||
{value: 'shop', text: 'Shop'},
|
||||
{value: 'tourism', text: 'Tourism'},
|
||||
],
|
||||
'views': [
|
||||
{value: 'cloud', text: 'Cloud'},
|
||||
{value: 'list', text: 'List'},
|
||||
],
|
||||
'groups': [
|
||||
{value: 'a', text: 'Animals'},
|
||||
{value: 'b', text: 'People'},
|
||||
],
|
||||
'sorting': [
|
||||
{value: 'newest', text: 'Mostly used'},
|
||||
{value: 'oldest', text: 'Rarely used'},
|
||||
],
|
||||
},
|
||||
'listColumns': [
|
||||
{text: 'Label', value: 'PhotoTitle'},
|
||||
{text: 'Created At', value: 'TakenAt'},
|
||||
{text: 'Id', value: 'LocCity'},
|
||||
{text: 'Nr of photos', value: 'Nr'},
|
||||
{text: 'Actions', value: 'Edit'},
|
||||
],
|
||||
'view': view,
|
||||
'loadMoreDisabled': true,
|
||||
'pageSize': 60,
|
||||
'offset': 0,
|
||||
'lastQuery': {},
|
||||
'submitTimeout': false,
|
||||
'selected': [],
|
||||
'dialog': false,
|
||||
'dialog2': false,
|
||||
select: null,
|
||||
items: [
|
||||
'English',
|
||||
'German',
|
||||
'French',
|
||||
'Spanish'
|
||||
],
|
||||
items2: [
|
||||
'Holiday',
|
||||
'Nature',
|
||||
'Animals',
|
||||
],
|
||||
results: [],
|
||||
scrollDisabled: true,
|
||||
pageSize: 24,
|
||||
offset: 0,
|
||||
selection: this.$clipboard.selection,
|
||||
settings: settings,
|
||||
filter: filter,
|
||||
lastFilter: {},
|
||||
};
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
},
|
||||
methods: {
|
||||
handleResize() {
|
||||
this.window.width = window.innerWidth;
|
||||
this.window.height = window.innerHeight;
|
||||
},
|
||||
clearSelection() {
|
||||
for (let i = 0; i < this.selected.length; i++) {
|
||||
this.selected[i].selected = false;
|
||||
}
|
||||
this.selected = [];
|
||||
this.updateSnackbar();
|
||||
},
|
||||
updateSnackbar(text) {
|
||||
if (!text) text = "";
|
||||
|
||||
this.snackbarText = text;
|
||||
|
||||
this.snackbarVisible = this.snackbarText !== "";
|
||||
},
|
||||
showSnackbar() {
|
||||
this.snackbarVisible = this.snackbarText !== "";
|
||||
},
|
||||
hideSnackbar() {
|
||||
this.snackbarVisible = false;
|
||||
},
|
||||
selectPhoto(photo, ev) {
|
||||
if (photo.selected) {
|
||||
for (let i = 0; i < this.selected.length; i++) {
|
||||
if (this.selected[i].id === photo.id) {
|
||||
this.selected.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
photo.selected = false;
|
||||
} else {
|
||||
this.selected.push(photo);
|
||||
photo.selected = true;
|
||||
}
|
||||
|
||||
if (this.selected.length > 0) {
|
||||
if (this.selected.length === 1) {
|
||||
this.snackbarText = 'One photo selected';
|
||||
} else {
|
||||
this.snackbarText = this.selected.length + ' photos selected';
|
||||
}
|
||||
this.snackbarVisible = true;
|
||||
} else {
|
||||
this.snackbarText = '';
|
||||
this.snackbarVisible = false;
|
||||
}
|
||||
},
|
||||
likePhoto(photo) {
|
||||
photo.PhotoFavorite = !photo.PhotoFavorite;
|
||||
photo.like(photo.PhotoFavorite);
|
||||
},
|
||||
deletePhoto(photo) {
|
||||
this.$alert.success('Photo deleted');
|
||||
},
|
||||
formChange(event) {
|
||||
this.search();
|
||||
},
|
||||
clearQuery() {
|
||||
this.query.q = '';
|
||||
this.filter.q = '';
|
||||
this.search();
|
||||
},
|
||||
openPhoto(index) {
|
||||
this.$viewer.show(this.results, index)
|
||||
openLabel(index) {
|
||||
const label = this.results[index];
|
||||
this.$router.push({name: 'Photos', query: {q: "label:" + label.LabelSlug}});
|
||||
},
|
||||
loadMore() {
|
||||
if (this.loadMoreDisabled) return;
|
||||
if (this.scrollDisabled) return;
|
||||
|
||||
this.loadMoreDisabled = true;
|
||||
this.scrollDisabled = true;
|
||||
|
||||
this.offset += this.pageSize;
|
||||
|
||||
|
@ -420,59 +137,74 @@
|
|||
offset: this.offset,
|
||||
};
|
||||
|
||||
Object.assign(params, this.lastQuery);
|
||||
Object.assign(params, this.lastFilter);
|
||||
|
||||
Photo.search(params).then(response => {
|
||||
Label.search(params).then(response => {
|
||||
this.results = this.results.concat(response.models);
|
||||
|
||||
this.loadMoreDisabled = (response.models.length < this.pageSize);
|
||||
this.scrollDisabled = (response.models.length < this.pageSize);
|
||||
|
||||
if (this.loadMoreDisabled) {
|
||||
this.$alert.info('All ' + this.results.length + ' photos loaded');
|
||||
if (this.scrollDisabled) {
|
||||
this.$alert.info('All ' + this.results.length + ' labels loaded');
|
||||
}
|
||||
});
|
||||
},
|
||||
search() {
|
||||
this.loadMoreDisabled = true;
|
||||
updateQuery() {
|
||||
const query = {
|
||||
view: this.settings.view
|
||||
};
|
||||
|
||||
// Don't query the same data more than once:197
|
||||
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
|
||||
|
||||
Object.assign(this.lastQuery, this.query);
|
||||
|
||||
this.offset = 0;
|
||||
|
||||
this.$router.replace({query: this.query});
|
||||
Object.assign(query, this.filter);
|
||||
|
||||
this.$router.replace({query: query});
|
||||
},
|
||||
searchParams() {
|
||||
const params = {
|
||||
count: this.pageSize,
|
||||
offset: this.offset,
|
||||
};
|
||||
|
||||
Object.assign(params, this.query);
|
||||
Object.assign(params, this.filter);
|
||||
|
||||
Photo.search(params).then(response => {
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
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.updateQuery();
|
||||
|
||||
const params = this.searchParams();
|
||||
|
||||
Label.search(params).then(response => {
|
||||
this.results = response.models;
|
||||
|
||||
this.loadMoreDisabled = (response.models.length < this.pageSize);
|
||||
this.scrollDisabled = (response.models.length < this.pageSize);
|
||||
|
||||
if (this.loadMoreDisabled) {
|
||||
this.$alert.info(this.results.length + ' photos found');
|
||||
if (this.scrollDisabled) {
|
||||
this.$alert.info(this.results.length + ' labels found');
|
||||
} else {
|
||||
this.$alert.info('More than 50 photos found');
|
||||
this.$alert.info('More than 50 labels found');
|
||||
|
||||
this.$nextTick(() => this.$emit("scrollRefresh"));
|
||||
}
|
||||
});
|
||||
},
|
||||
translation() {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
next()
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
this.handleResize();
|
||||
this.search();
|
||||
},
|
||||
};
|
||||
|
|
29
frontend/src/pages/people.vue
Normal file
29
frontend/src/pages/people.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<v-toolbar-title>Not implemented yet</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>
|
|
@ -29,6 +29,9 @@
|
|||
},
|
||||
watch: {
|
||||
'$route' () {
|
||||
const query = this.$route.query;
|
||||
|
||||
this.filter.q = query['q'];
|
||||
this.lastFilter = {};
|
||||
this.search();
|
||||
}
|
||||
|
@ -66,6 +69,8 @@
|
|||
return storedType;
|
||||
} else if(window.innerWidth < 960) {
|
||||
return 'mosaic';
|
||||
} else if(window.innerWidth > 1600) {
|
||||
return 'details';
|
||||
}
|
||||
|
||||
return 'tiles';
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<l-marker v-for="photo in photos" v-bind:data="photo"
|
||||
v-bind:key="photo.index" :lat-lng="photo.location" :icon="photo.icon"
|
||||
:options="photo.options" @click="openPhoto(photo.index)"></l-marker>
|
||||
<l-marker v-if="position" :lat-lng="position" :z-index-offset="1"></l-marker>
|
||||
<l-marker v-if="position" :lat-lng="position" :z-index-offset="100"></l-marker>
|
||||
</l-map>
|
||||
</v-container>
|
||||
</template>
|
||||
|
@ -163,8 +163,10 @@
|
|||
this.results = results;
|
||||
this.photos = photos;
|
||||
|
||||
this.center = photos[0].location;
|
||||
this.bounds = [[this.maxLat, this.minLong], [this.minLat, this.maxLong]];
|
||||
this.$nextTick(() => {
|
||||
this.center = photos[0].location;
|
||||
this.bounds = [[this.maxLat, this.minLong], [this.minLat, this.maxLong]];
|
||||
});
|
||||
|
||||
if (photos.length > 100) {
|
||||
this.$alert.info('More than 100 photos found');
|
||||
|
|
|
@ -10,7 +10,8 @@ import Export from "pages/export.vue";
|
|||
import Settings from "pages/settings.vue";
|
||||
import Labels from "pages/labels.vue";
|
||||
import Todo from "pages/todo.vue";
|
||||
import Calendar from "pages/calendar.vue";
|
||||
import Events from "pages/events.vue";
|
||||
import People from "pages/people.vue";
|
||||
|
||||
export default [
|
||||
{
|
||||
|
@ -37,10 +38,28 @@ export default [
|
|||
component: PlacesPage,
|
||||
meta: {area: "Places"},
|
||||
},
|
||||
{
|
||||
name: "Labels",
|
||||
path: "/labels",
|
||||
component: Labels,
|
||||
meta: {area: "Labels"},
|
||||
},
|
||||
{
|
||||
name: "Events",
|
||||
path: "/events",
|
||||
component: Events,
|
||||
meta: {area: "Events"},
|
||||
},
|
||||
{
|
||||
name: "People",
|
||||
path: "/people",
|
||||
component: People,
|
||||
meta: {area: "People"},
|
||||
},
|
||||
{
|
||||
name: "PhotosEdit",
|
||||
path: "/photosEdit",
|
||||
component: PhotosEdit,
|
||||
component: Todo,
|
||||
meta: {area: "Photos"},
|
||||
},
|
||||
{
|
||||
|
@ -49,32 +68,13 @@ export default [
|
|||
component: Todo,
|
||||
meta: {area: "Filters"},
|
||||
},
|
||||
{
|
||||
name: "Calendar",
|
||||
path: "/calendar",
|
||||
component: Calendar,
|
||||
meta: {area: "Calendar"},
|
||||
},
|
||||
{
|
||||
name: "Labels",
|
||||
path: "/labels",
|
||||
component: Labels,
|
||||
meta: {area: "Labels"},
|
||||
},
|
||||
{
|
||||
name: "Bookmarks",
|
||||
path: "/bookmarks",
|
||||
component: Todo,
|
||||
meta: {area: "Bookmarks"},
|
||||
},
|
||||
{
|
||||
name: "Albums",
|
||||
path: "/albums",
|
||||
component: Albums,
|
||||
component: Todo,
|
||||
meta: {area: "Albums"},
|
||||
},
|
||||
{
|
||||
|
||||
name: "Albums2",
|
||||
path: "/albums2",
|
||||
component: Albums2,
|
||||
|
@ -83,7 +83,7 @@ export default [
|
|||
{
|
||||
name: "Import",
|
||||
path: "/import",
|
||||
component: Import,
|
||||
component: Todo,
|
||||
meta: {area: "Import"},
|
||||
},
|
||||
{
|
||||
|
@ -99,13 +99,13 @@ export default [
|
|||
{
|
||||
name: "Export",
|
||||
path: "/export",
|
||||
component: Export,
|
||||
component: Todo,
|
||||
meta: {area: "Export"},
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: "/settings",
|
||||
component: Settings,
|
||||
component: Todo,
|
||||
meta: {area: "Settings"},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -15,13 +15,5 @@ test('Navigate', async t => {
|
|||
await page.openNav();
|
||||
await t
|
||||
.click('a[href="/labels"]')
|
||||
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Labels');
|
||||
await page.openNav();
|
||||
await t
|
||||
.click('a[href="/albums"]')
|
||||
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Albums');
|
||||
await page.openNav();
|
||||
await t
|
||||
.click('a[href="/import"]')
|
||||
.expect(Selector('h1').innerText, {timeout: 9000}).contains('Import');
|
||||
.expect(Selector('main .p-page-labels').exists, {timeout: 5000}).ok();
|
||||
});
|
||||
|
|
|
@ -10,7 +10,8 @@ export default class Page {
|
|||
}
|
||||
|
||||
async setFilter(filter, option) {
|
||||
await t
|
||||
await t;
|
||||
|
||||
switch (filter) {
|
||||
case 'view':
|
||||
await t
|
||||
|
|
|
@ -21,7 +21,7 @@ test('Select photos', async t => {
|
|||
await page.openNav();
|
||||
await t
|
||||
.click('a[href="/labels"]')
|
||||
.expect(Selector('h1').innerText, {timeout: 5000}).contains('Labels');
|
||||
.expect(Selector('main .p-page-labels').exists, {timeout: 5000}).ok();
|
||||
await page.openNav();
|
||||
await t
|
||||
.click('a[href="/photos"]')
|
||||
|
|
83
internal/api/labels.go
Normal file
83
internal/api/labels.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/forms"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/util"
|
||||
)
|
||||
|
||||
// GET /api/v1/labels
|
||||
func GetLabels(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.GET("/labels", func(c *gin.Context) {
|
||||
var form forms.LabelSearchForm
|
||||
|
||||
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
|
||||
err := c.MustBindWith(&form, binding.Form)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := search.Labels(form)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("x-result-count", strconv.Itoa(form.Count))
|
||||
c.Header("x-result-offset", strconv.Itoa(form.Offset))
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/labels/:slug/like
|
||||
//
|
||||
// Parameters:
|
||||
// slug: string Label slug name
|
||||
func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.POST("/labels/:slug/like", func(c *gin.Context) {
|
||||
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
|
||||
|
||||
label, err := search.FindLabelBySlug(c.Param("slug"))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
label.LabelFavorite = true
|
||||
conf.Db().Save(&label)
|
||||
|
||||
c.JSON(http.StatusOK, http.Response{})
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/labels/:slug/like
|
||||
//
|
||||
// Parameters:
|
||||
// slug: string Label slug name
|
||||
func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.DELETE("/labels/:slug/like", func(c *gin.Context) {
|
||||
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
|
||||
|
||||
label, err := search.FindLabelBySlug(c.Param("slug"))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
label.LabelFavorite = false
|
||||
conf.Db().Save(&label)
|
||||
|
||||
c.JSON(http.StatusOK, http.Response{})
|
||||
})
|
||||
}
|
|
@ -63,3 +63,62 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/labels/:slug/thumbnail/:type
|
||||
//
|
||||
// Example: /api/v1/labels/cheetah/thumbnail/tile_500
|
||||
//
|
||||
// Parameters:
|
||||
// slug: string Label slug name
|
||||
// type: string Thumbnail type, see photoprism.ThumbnailTypes
|
||||
func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.GET("/labels/:slug/thumbnail/:type", func(c *gin.Context) {
|
||||
typeName := c.Param("type")
|
||||
|
||||
thumbType, ok := photoprism.ThumbnailTypes[typeName]
|
||||
|
||||
if !ok {
|
||||
log.Errorf("invalid type: %s", typeName)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
|
||||
|
||||
// log.Infof("Searching for label slug: %s", c.Param("slug"))
|
||||
|
||||
file, err := search.FindLabelThumbBySlug(c.Param("slug"))
|
||||
|
||||
// log.Infof("Label thumb file: %#v", file)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s/%s", conf.OriginalsPath(), file.FileName)
|
||||
|
||||
if !util.Exists(fileName) {
|
||||
log.Errorf("could not find original for thumbnail: %s", fileName)
|
||||
c.Data(404, "image/svg+xml", photoIconSvg)
|
||||
|
||||
// Set missing flag so that the file doesn't show up in search results anymore
|
||||
file.FileMissing = true
|
||||
conf.Db().Save(&file)
|
||||
return
|
||||
}
|
||||
|
||||
if thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil {
|
||||
if c.Query("download") != "" {
|
||||
downloadFileName := file.DownloadFileName()
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName))
|
||||
}
|
||||
|
||||
c.File(thumbnail)
|
||||
} else {
|
||||
log.Errorf("could not create thumbnail: %s", err)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
116
internal/forms/label_search.go
Normal file
116
internal/forms/label_search.go
Normal file
|
@ -0,0 +1,116 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
// Query parameters for GET /api/v1/labels
|
||||
type LabelSearchForm struct {
|
||||
Query string `form:"q"`
|
||||
|
||||
Slug string `form:"slug"`
|
||||
Name string `form:"name"`
|
||||
Priority int `form:"priority"`
|
||||
Favorites bool `form:"favorites"`
|
||||
|
||||
Count int `form:"count" binding:"required"`
|
||||
Offset int `form:"offset"`
|
||||
Order string `form:"order"`
|
||||
}
|
||||
|
||||
func (f *LabelSearchForm) ParseQueryString() (result error) {
|
||||
var key, value []byte
|
||||
var escaped, isKeyValue bool
|
||||
|
||||
query := f.Query
|
||||
|
||||
f.Query = ""
|
||||
|
||||
formValues := reflect.ValueOf(f).Elem()
|
||||
|
||||
query = strings.TrimSpace(query) + "\n"
|
||||
|
||||
for _, char := range query {
|
||||
if unicode.IsSpace(char) && !escaped {
|
||||
if isKeyValue {
|
||||
fieldName := string(bytes.Title(bytes.ToLower(key)))
|
||||
field := formValues.FieldByName(fieldName)
|
||||
stringValue := string(bytes.ToLower(value))
|
||||
|
||||
if field.CanSet() {
|
||||
switch field.Interface().(type) {
|
||||
case time.Time:
|
||||
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.Set(reflect.ValueOf(timeValue))
|
||||
}
|
||||
case float64:
|
||||
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetFloat(floatValue)
|
||||
}
|
||||
case int, int64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetInt(int64(intValue))
|
||||
}
|
||||
case uint, uint64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetUint(uint64(intValue))
|
||||
}
|
||||
case string:
|
||||
field.SetString(stringValue)
|
||||
case bool:
|
||||
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
|
||||
field.SetBool(true)
|
||||
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
|
||||
field.SetBool(false)
|
||||
} else {
|
||||
result = fmt.Errorf("not a bool value: %s", fieldName)
|
||||
}
|
||||
default:
|
||||
result = fmt.Errorf("unsupported type: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
result = fmt.Errorf("unknown filter: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
f.Query = string(bytes.ToLower(key))
|
||||
}
|
||||
|
||||
escaped = false
|
||||
isKeyValue = false
|
||||
key = key[:0]
|
||||
value = value[:0]
|
||||
} else if char == ':' {
|
||||
isKeyValue = true
|
||||
} else if char == '"' {
|
||||
escaped = !escaped
|
||||
} else if isKeyValue {
|
||||
value = append(value, byte(char))
|
||||
} else {
|
||||
key = append(key, byte(char))
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
log.Errorf("error while parsing label form: %s", result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -13,9 +13,10 @@ type Label struct {
|
|||
LabelSlug string `gorm:"type:varchar(128);index;"`
|
||||
LabelName string `gorm:"type:varchar(128);index;"`
|
||||
LabelPriority int
|
||||
LabelFavorite bool
|
||||
LabelDescription string `gorm:"type:text;"`
|
||||
LabelNotes string `gorm:"type:text;"`
|
||||
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"`
|
||||
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id"`
|
||||
}
|
||||
|
||||
func NewLabel(labelName string, labelPriority int) *Label {
|
||||
|
@ -28,8 +29,8 @@ func NewLabel(labelName string, labelPriority int) *Label {
|
|||
labelSlug := slug.Make(labelName)
|
||||
|
||||
result := &Label{
|
||||
LabelName: labelName,
|
||||
LabelSlug: labelSlug,
|
||||
LabelName: labelName,
|
||||
LabelSlug: labelSlug,
|
||||
LabelPriority: labelPriority,
|
||||
}
|
||||
|
||||
|
|
|
@ -6,8 +6,8 @@ import (
|
|||
|
||||
// Photo labels are weighted by uncertainty (100 - confidence)
|
||||
type PhotoLabel struct {
|
||||
PhotoID uint `gorm:"primary_key;auto_increment:false"`
|
||||
LabelID uint `gorm:"primary_key;auto_increment:false"`
|
||||
PhotoID uint `gorm:"primary_key;auto_increment:false"`
|
||||
LabelUncertainty int
|
||||
LabelSource string
|
||||
Photo *Photo
|
||||
|
@ -15,7 +15,7 @@ type PhotoLabel struct {
|
|||
}
|
||||
|
||||
func (PhotoLabel) TableName() string {
|
||||
return "photo_labels"
|
||||
return "photos_labels"
|
||||
}
|
||||
|
||||
func NewPhotoLabel(photoId, labelId uint, uncertainty int, source string) *PhotoLabel {
|
||||
|
|
|
@ -25,71 +25,6 @@ type SearchCount struct {
|
|||
Total int
|
||||
}
|
||||
|
||||
// PhotoSearchResult is a found mediafile.
|
||||
type PhotoSearchResult struct {
|
||||
// Photo
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt time.Time
|
||||
TakenAt time.Time
|
||||
TimeZone string
|
||||
PhotoTitle string
|
||||
PhotoDescription string
|
||||
PhotoArtist string
|
||||
PhotoKeywords string
|
||||
PhotoColors string
|
||||
PhotoColor string
|
||||
PhotoCanonicalName string
|
||||
PhotoLat float64
|
||||
PhotoLong float64
|
||||
PhotoFavorite bool
|
||||
|
||||
// Camera
|
||||
CameraID uint
|
||||
CameraModel string
|
||||
CameraMake string
|
||||
|
||||
// Lens
|
||||
LensID uint
|
||||
LensModel string
|
||||
LensMake string
|
||||
|
||||
// Country
|
||||
CountryID string
|
||||
CountryName string
|
||||
|
||||
// Location
|
||||
LocationID uint
|
||||
LocDisplayName string
|
||||
LocName string
|
||||
LocCity string
|
||||
LocPostcode string
|
||||
LocCounty string
|
||||
LocState string
|
||||
LocCountry string
|
||||
LocCountryCode string
|
||||
LocCategory string
|
||||
LocType string
|
||||
|
||||
// File
|
||||
FileID uint
|
||||
FilePrimary bool
|
||||
FileMissing bool
|
||||
FileName string
|
||||
FileHash string
|
||||
FilePerceptualHash string
|
||||
FileType string
|
||||
FileMime string
|
||||
FileWidth int
|
||||
FileHeight int
|
||||
FileOrientation int
|
||||
FileAspectRatio float64
|
||||
|
||||
// List of matching labels (tags)
|
||||
Labels string
|
||||
}
|
||||
|
||||
// NewSearch returns a new Search type with a given path and db instance.
|
||||
func NewSearch(originalsPath string, db *gorm.DB) *Search {
|
||||
instance := &Search{
|
||||
|
@ -128,8 +63,8 @@ func (s *Search) Photos(form forms.PhotoSearchForm) (results []PhotoSearchResult
|
|||
Joins("JOIN lenses ON lenses.id = photos.lens_id").
|
||||
Joins("LEFT JOIN countries ON countries.id = photos.country_id").
|
||||
Joins("LEFT JOIN locations ON locations.id = photos.location_id").
|
||||
Joins("LEFT JOIN photo_labels ON photo_labels.photo_id = photos.id").
|
||||
Joins("LEFT JOIN labels ON photo_labels.label_id = labels.id").
|
||||
Joins("LEFT JOIN photos_labels ON photos_labels.photo_id = photos.id").
|
||||
Joins("LEFT JOIN labels ON photos_labels.label_id = labels.id").
|
||||
Where("photos.deleted_at IS NULL AND files.file_missing = 0").
|
||||
Group("photos.id, files.id")
|
||||
|
||||
|
@ -226,8 +161,10 @@ func (s *Search) Photos(form forms.PhotoSearchForm) (results []PhotoSearchResult
|
|||
|
||||
if form.Mono {
|
||||
q = q.Where("files.file_chroma = 0")
|
||||
} else if form.Chroma > 0 {
|
||||
} else if form.Chroma > 3 {
|
||||
q = q.Where("files.file_chroma > ?", form.Chroma)
|
||||
} else if form.Chroma > 0 {
|
||||
q = q.Where("files.file_chroma > 0 AND files.file_chroma <= ?", form.Chroma)
|
||||
}
|
||||
|
||||
if form.Fmin > 0 {
|
||||
|
@ -325,3 +262,101 @@ func (s *Search) FindPhotoByID(photoID uint64) (photo models.Photo, err error) {
|
|||
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// FindLabelBySlug returns a Label based on the slug name.
|
||||
func (s *Search) FindLabelBySlug(labelSlug string) (label models.Label, err error) {
|
||||
if err := s.db.Where("label_slug = ?", labelSlug).First(&label).Error; err != nil {
|
||||
return label, err
|
||||
}
|
||||
|
||||
return label, nil
|
||||
}
|
||||
|
||||
// FindLabelThumbBySlug returns a label preview file based on the slug name.
|
||||
func (s *Search) FindLabelThumbBySlug(labelSlug string) (file models.File, err error) {
|
||||
// s.db.LogMode(true)
|
||||
|
||||
if err := s.db.Where("files.file_primary AND files.deleted_at IS NULL").
|
||||
Joins("JOIN labels ON labels.label_slug = ?", labelSlug).
|
||||
Joins("JOIN photos_labels ON photos_labels.label_id = labels.id AND photos_labels.photo_id = files.photo_id").
|
||||
Order("photos_labels.label_uncertainty ASC").
|
||||
First(&file).Error; err != nil {
|
||||
return file, err
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// Labels searches labels based on their name.
|
||||
func (s *Search) Labels(form forms.LabelSearchForm) (results []LabelSearchResult, err error) {
|
||||
if err := form.ParseQueryString(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
defer util.ProfileTime(time.Now(), fmt.Sprintf("search for %+v", form))
|
||||
|
||||
q := s.db.NewScope(nil).DB()
|
||||
|
||||
// q.LogMode(true)
|
||||
|
||||
q = q.Table("labels").
|
||||
Select(`labels.*, COUNT(photos_labels.label_id) AS label_count`).
|
||||
Joins("JOIN photos_labels ON photos_labels.label_id = labels.id").
|
||||
Where("labels.deleted_at IS NULL").
|
||||
Group("labels.id")
|
||||
|
||||
if form.Query != "" {
|
||||
var labelIds []uint
|
||||
var categories []models.Category
|
||||
var label models.Label
|
||||
|
||||
likeString := "%" + strings.ToLower(form.Query) + "%"
|
||||
|
||||
if result := s.db.First(&label, "LOWER(label_name) LIKE LOWER(?)", form.Query); result.Error != nil {
|
||||
log.Infof("label \"%s\" not found", form.Query)
|
||||
|
||||
q = q.Where("LOWER(labels.label_name) LIKE ?", likeString)
|
||||
} else {
|
||||
labelIds = append(labelIds, label.ID)
|
||||
|
||||
s.db.Where("category_id = ?", label.ID).Find(&categories)
|
||||
|
||||
for _, category := range categories {
|
||||
labelIds = append(labelIds, category.LabelID)
|
||||
}
|
||||
|
||||
log.Infof("searching for label IDs: %#v", form.Query)
|
||||
|
||||
q = q.Where("labels.id IN (?) OR LOWER(labels.label_name) LIKE ?", labelIds, likeString)
|
||||
}
|
||||
}
|
||||
|
||||
if form.Favorites {
|
||||
q = q.Where("labels.label_favorite = 1")
|
||||
}
|
||||
|
||||
if form.Priority !=0 {
|
||||
q = q.Where("labels.label_priority > ?", form.Priority)
|
||||
} else {
|
||||
q = q.Where("labels.label_priority >= -1")
|
||||
}
|
||||
|
||||
switch form.Order {
|
||||
case "slug":
|
||||
q = q.Order("labels.label_favorite DESC, label_slug ASC")
|
||||
default:
|
||||
q = q.Order("labels.label_favorite DESC, labels.label_priority DESC, label_count DESC, labels.created_at DESC")
|
||||
}
|
||||
|
||||
if form.Count > 0 && form.Count <= 1000 {
|
||||
q = q.Limit(form.Count).Offset(form.Offset)
|
||||
} else {
|
||||
q = q.Limit(100).Offset(0)
|
||||
}
|
||||
|
||||
if result := q.Scan(&results); result.Error != nil {
|
||||
return results, result.Error
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
|
84
internal/photoprism/search_result.go
Normal file
84
internal/photoprism/search_result.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package photoprism
|
||||
|
||||
import "time"
|
||||
|
||||
// PhotoSearchResult contains found photos and their main file plus other meta data.
|
||||
type PhotoSearchResult struct {
|
||||
// Photo
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt time.Time
|
||||
TakenAt time.Time
|
||||
TimeZone string
|
||||
PhotoTitle string
|
||||
PhotoDescription string
|
||||
PhotoArtist string
|
||||
PhotoKeywords string
|
||||
PhotoColors string
|
||||
PhotoColor string
|
||||
PhotoCanonicalName string
|
||||
PhotoLat float64
|
||||
PhotoLong float64
|
||||
PhotoFavorite bool
|
||||
|
||||
// Camera
|
||||
CameraID uint
|
||||
CameraModel string
|
||||
CameraMake string
|
||||
|
||||
// Lens
|
||||
LensID uint
|
||||
LensModel string
|
||||
LensMake string
|
||||
|
||||
// Country
|
||||
CountryID string
|
||||
CountryName string
|
||||
|
||||
// Location
|
||||
LocationID uint
|
||||
LocDisplayName string
|
||||
LocName string
|
||||
LocCity string
|
||||
LocPostcode string
|
||||
LocCounty string
|
||||
LocState string
|
||||
LocCountry string
|
||||
LocCountryCode string
|
||||
LocCategory string
|
||||
LocType string
|
||||
|
||||
// File
|
||||
FileID uint
|
||||
FilePrimary bool
|
||||
FileMissing bool
|
||||
FileName string
|
||||
FileHash string
|
||||
FilePerceptualHash string
|
||||
FileType string
|
||||
FileMime string
|
||||
FileWidth int
|
||||
FileHeight int
|
||||
FileOrientation int
|
||||
FileAspectRatio float64
|
||||
|
||||
// List of matching labels (tags)
|
||||
Labels string
|
||||
}
|
||||
|
||||
// LabelSearchResult contains found labels
|
||||
type LabelSearchResult struct {
|
||||
// Label
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt time.Time
|
||||
LabelSlug string
|
||||
LabelName string
|
||||
LabelPriority int
|
||||
LabelCount int
|
||||
LabelFavorite bool
|
||||
LabelDescription string
|
||||
LabelNotes string
|
||||
}
|
|
@ -18,11 +18,17 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
// JSON-REST API Version 1
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
api.GetPhotos(v1, conf)
|
||||
api.GetThumbnail(v1, conf)
|
||||
api.GetDownload(v1, conf)
|
||||
|
||||
api.GetPhotos(v1, conf)
|
||||
api.LikePhoto(v1, conf)
|
||||
api.DislikePhoto(v1, conf)
|
||||
|
||||
api.GetLabels(v1, conf)
|
||||
api.LikeLabel(v1, conf)
|
||||
api.DislikeLabel(v1, conf)
|
||||
api.LabelThumbnail(v1, conf)
|
||||
}
|
||||
|
||||
// Default HTML page (client-side routing implemented via Vue.js)
|
||||
|
|
Loading…
Reference in a new issue