Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Theresa Gresch 2020-04-26 13:48:11 +02:00
commit f5b80ae395
71 changed files with 645 additions and 524 deletions

View file

@ -13,8 +13,10 @@ features:
edit: true
share: true
library:
rescan: false
raw: false
thumbs: false
group: true
rescan: false
move: false
private: true
review: true
group: true

View file

@ -1,4 +1,3 @@
INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (1, 'unknown', 'Unknown', '', '', '', '', '2020-01-06 02:06:29', '2020-01-06 02:07:26', null);
INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (2, 'apple-iphone-se', 'iPhone SE', 'Apple', '', '', '', '2020-01-06 02:06:30', '2020-01-06 02:07:28', null);
INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (3, 'canon-eos-5d', 'EOS 5D', 'Canon', '', '', '', '2020-01-06 02:06:32', '2020-01-06 02:06:32', null);
INSERT INTO cameras (id, camera_slug, camera_model, camera_make, camera_type, camera_description, camera_notes, created_at, updated_at, deleted_at) VALUES (4, 'canon-eos-7d', 'EOS 7D', 'Canon', '', '', '', '2020-01-06 02:06:33', '2020-01-06 02:06:33', null);
@ -17,12 +16,12 @@ INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary,
INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (3, '2', '655', 'fq8ev8t1x0bwje4e', 'exampleXmpFile.xmp', 0, '125xxx', 0);
INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (4, '5', '658', 'fq8ev9c3sp88uwzq', 'bridge.jpg', 1, '126xxx', 0);
INSERT INTO files (id, photo_id, photo_uuid, file_uuid, file_name, file_primary, file_hash, file_missing) VALUES (5, '6', '659', 'fq8evan3urz3i48d', 'reunion.jpg', 1, '127xxx', 0);
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (1, '654', 2790, 2, '48.519235', '9.057996666666666');
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (2, '655', 2790, 2, '48.519235', '9.057996666666666');
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (3, '656', 1990, 3, '48.519235', '9.057996666666666');
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (4, '657', 1990, 4, '48.519235', '9.057996666666666');
INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (5, '658', '2014-07-17 15:42:12', '48.519235', '9.057996666666666', 'Neckarbrücke');
INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (6, '659', '2015-11-11 09:07:18', '-21.34263611111111', '55.466944444444444', 'Reunion');
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (1, '654', 2790, 2, 48.519234, 9.057997);
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (2, '655', 2790, 2, 48.519234, 9.057997);
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (3, '656', 1990, 3, 48.519234, 9.057997);
INSERT INTO photos (id, photo_uuid, photo_year, photo_month, photo_lat, photo_lng) VALUES (4, '657', 1990, 4, 48.519234, 9.057997);
INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (5, '658', '2014-07-17 15:42:12', 48.519235, 9.05799666, 'Neckarbrücke');
INSERT INTO photos (id, photo_uuid, taken_at, photo_lat, photo_lng, photo_title) VALUES (6, '659', '2015-11-11 09:07:18', -21.342636, 55.466944, 'Reunion');
INSERT INTO keywords (id, keyword, skip) VALUES (1, 'bridge', 0);
INSERT INTO keywords (id, keyword, skip) VALUES (2, 'beach', 0);
INSERT INTO photos_keywords (photo_id, keyword_id) VALUES (5, 1);

View file

@ -27,6 +27,8 @@ services:
PHOTOPRISM_PID_FILENAME: "photoprism.pid"
PHOTOPRISM_LOG_FILENAME: "photoprism.log"
PHOTOPRISM_DETACH_SERVER: "true"
PHOTOPRISM_UPLOAD_NSFW: "false"
PHOTOPRISM_DETECT_NSFW: "true"
CODECOV_TOKEN:
CODECOV_ENV:
CODECOV_URL:

View file

@ -23,7 +23,7 @@ services:
PHOTOPRISM_PUBLIC: "false"
PHOTOPRISM_EXPERIMENTAL: "true"
PHOTOPRISM_UPLOAD_NSFW: "false"
PHOTOPRISM_HIDE_NSFW: "false"
PHOTOPRISM_DETECT_NSFW: "true"
PHOTOPRISM_SERVER_MODE: "debug"
PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets"
PHOTOPRISM_CACHE_PATH: "/go/src/github.com/photoprism/photoprism/assets/cache"

View file

@ -11,7 +11,7 @@ ENV PHOTOPRISM_READONLY false
ENV PHOTOPRISM_PUBLIC true
ENV PHOTOPRISM_EXPERIMENTAL true
ENV PHOTOPRISM_UPLOAD_NSFW false
ENV PHOTOPRISM_HIDE_NSFW false
ENV PHOTOPRISM_DETECT_NSFW true
ENV PHOTOPRISM_THUMB_QUALITY 95
ENV PHOTOPRISM_THUMB_SIZE 3840
ENV PHOTOPRISM_THUMB_LIMIT 3840

View file

@ -13,6 +13,12 @@ services:
- seccomp:unconfined
ports:
- 2342:2342 # [local port]:[container port]
# - 4000:4000 # Internal database (MySQL compatible)
healthcheck: # Optional
test: "photoprism status"
interval: 60s
timeout: 15s
retries: 5
environment: # Run "photoprism help" and "photoprism config" too see all config options and current values
PHOTOPRISM_URL: "https://demo.photoprism.org/"
PHOTOPRISM_TITLE: "PhotoPrism"
@ -21,7 +27,7 @@ services:
PHOTOPRISM_AUTHOR: "Anonymous"
PHOTOPRISM_TWITTER: "@browseyourlife"
PHOTOPRISM_UPLOAD_NSFW: "true"
PHOTOPRISM_HIDE_NSFW: "false"
PHOTOPRISM_DETECT_NSFW: "false"
PHOTOPRISM_EXPERIMENTAL: "false"
PHOTOPRISM_DEBUG: "false"
PHOTOPRISM_READONLY: "false"

View file

@ -12,7 +12,8 @@ services:
restart: unless-stopped
ports:
- 2342:2342 # [local port]:[container port]
healthcheck:
# - 4000:4000 # Internal database (MySQL compatible)
healthcheck: # Optional
test: "photoprism status"
interval: 60s
timeout: 15s
@ -25,7 +26,7 @@ services:
PHOTOPRISM_AUTHOR: "Anonymous"
PHOTOPRISM_TWITTER: "@browseyourlife"
PHOTOPRISM_UPLOAD_NSFW: "true"
PHOTOPRISM_HIDE_NSFW: "false"
PHOTOPRISM_DETECT_NSFW: "true"
PHOTOPRISM_EXPERIMENTAL: "false"
PHOTOPRISM_DEBUG: "false"
PHOTOPRISM_READONLY: "false"

View file

@ -21,7 +21,6 @@ import VueLuxon from "vue-luxon";
import VueFilters from "vue2-filters";
import VueFullscreen from "vue-fullscreen";
import VueInfiniteScroll from "vue-infinite-scroll";
import VueLongClick from "common/longclick";
// Initialize helpers
const viewer = new Viewer();
@ -53,8 +52,6 @@ Vue.use(GetTextPlugin, {
defaultLanguage: Vue.config.language,
});
Vue.directive("longclick", VueLongClick({delay: 450, interval: 0}));
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);

View file

@ -18,10 +18,12 @@ class Config {
title: "PhotoPrism",
};
this.$vuetify = null;
Event.subscribe("config.updated", (ev, data) => this.setValues(data));
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
if(this.hasValue("settings")) {
if (this.hasValue("settings")) {
this.setTheme(this.getValue("settings").theme);
} else {
this.setTheme("default");
@ -41,6 +43,10 @@ class Config {
}
}
if (values.settings) {
this.setTheme(values.settings.theme);
}
return this;
}
@ -73,14 +79,17 @@ class Config {
this.values.count;
}
updateSettings(settings, $vuetify) {
this.setValue("settings", settings);
this.setTheme(settings.theme);
$vuetify.theme = this.theme;
setVuetify(instance) {
this.$vuetify = instance;
}
setTheme(name) {
this.theme = themes[name] ? themes[name] : themes["default"];
if (this.$vuetify) {
this.$vuetify.theme = this.theme;
}
return this;
}

View file

@ -1,57 +0,0 @@
/*
VueJS long click directive
Based on https://github.com/ittus/vue-long-click (MIT License)
Author: Thang Minh Vu (https://github.com/ittus)
*/
export default ({delay = 400, interval = 50}) => ({
bind: function (el, binding, vNode) {
if (typeof binding.value !== "function") {
const compName = vNode.context.name;
let warn = `[longclick:] provided expression '${binding.expression}' is not a function, but has to be`;
if (compName) {
warn += `Found in component '${compName}' `;
}
console.warn(warn) // eslint-disable-line
}
let pressTimer = null;
let pressInterval = null;
const start = (e) => {
if (e.type === "click" && e.button !== 0) {
return;
}
if (pressTimer === null) {
pressTimer = setTimeout(() => {
if (interval && interval > 0) {
pressInterval = setInterval(() => {
handler();
}, interval);
}
handler();
}, delay);
}
};
// Cancel Timeout
const cancel = () => {
if (pressTimer !== null) {
clearTimeout(pressTimer);
pressTimer = null;
}
if (pressInterval) {
clearInterval(pressInterval);
pressInterval = null;
}
};
// Run Function
const handler = (e) => {
binding.value(e);
}
;["mousedown", "touchstart"].forEach(e => el.addEventListener(e, start))
;["click", "mouseout", "touchend", "touchcancel"].forEach(e => el.addEventListener(e, cancel));
},
});

View file

@ -76,7 +76,7 @@ class Viewer {
this.gallery = gallery;
gallery.listen('beforeChange', function() {
gallery.listen("beforeChange", function() {
Event.publish("viewer.change", {gallery: gallery, item: gallery.currItem});
});

View file

@ -154,8 +154,8 @@
{value: 'imported', text: this.$gettext('Recently imported')},
{value: 'newest', text: this.$gettext('Newest first')},
{value: 'oldest', text: this.$gettext('Oldest first')},
{value: 'similar', text: this.$gettext('Similar')},
{value: 'relevance', text: this.$gettext('Relevance')},
{value: 'similar', text: this.$gettext('Group by similarity')},
{value: 'relevance', text: this.$gettext('Most relevant')},
],
},
labels: {

View file

@ -79,7 +79,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{name: 'photos', query: { q: 'review:true' }}" :exact="true" @click="">
<v-list-tile to="/review" @click="" v-if="config.settings.library.review">
<v-list-tile-content>
<v-list-tile-title>
<translate>Review</translate>
@ -87,6 +87,14 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/private" @click="" v-if="config.settings.library.private">
<v-list-tile-content>
<v-list-tile-title>
<translate>Private</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/archive" @click="" class="p-navigation-archive" v-if="$config.feature('archive')">
<v-list-tile-content>
<v-list-tile-title>
@ -109,6 +117,19 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-if="mini && config.settings.library.private" to="/private" @click=""
class="p-navigation-private">
<v-list-tile-action>
<v-icon>lock</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
<translate>Private</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-if="mini && $config.feature('archive')" to="/archive" @click=""
class="p-navigation-archive">
<v-list-tile-action>

View file

@ -23,7 +23,7 @@
>
<v-hover>
<v-card tile slot-scope="{ hover }"
@contextmenu="contextMenu($event, photo, index)"
@contextmenu="onContextMenu($event, index)"
:dark="$clipboard.has(photo)"
:class="$clipboard.has(photo) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'">
<v-img
@ -32,8 +32,8 @@
v-bind:class="{ selected: $clipboard.has(photo) }"
style="cursor: pointer;"
class="accent lighten-2"
v-longclick="longClick"
@click="onClick($event, photo, index)"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-layout
slot="placeholder"
@ -45,26 +45,31 @@
<v-progress-circular indeterminate color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
icon large absolute
:class="$clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, photo, index)">
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
icon flat large absolute
class="p-photo-private opacity-75">
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
icon flat large absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && $clipboard.has(photo)" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
</v-btn>
<v-btn :flat="!hover" :ripple="false"
icon large absolute
<v-btn icon flat large absolute
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
@click.stop.prevent="photo.toggleLike()">
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
</v-btn>
<v-btn v-if="photo.Files.length > 1" :flat="!hover" :ripple="false"
icon large absolute class="p-photo-merged opacity-75"
<v-btn v-if="photo.Files.length > 1"
icon flat large absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
@ -115,51 +120,44 @@
data() {
return {
showLocation: this.$config.settings().features.places,
wasLong: false,
hidePrivate: this.$config.settings().library.private,
mouseDown: {
index: -1,
timeStamp: -1,
},
};
},
methods: {
longClick() {
this.wasLong = true;
},
onSelect(ev, model, index) {
onSelect(ev, index) {
if (ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
this.$clipboard.toggle(this.photos[index]);
}
this.wasLong = false;
},
onClick(ev, model, index) {
if (this.wasLong || this.selection.length > 0) {
ev.preventDefault();
ev.stopPropagation();
onMouseDown(ev, index) {
this.mouseDown.index = index;
this.mouseDown.timeStamp = ev.timeStamp;
},
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (this.wasLong || ev.shiftKey) {
if (longClick || this.selection.length > 0) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
this.$clipboard.toggle(this.photos[index]);
}
} else {
this.openPhoto(index, false);
}
this.wasLong = false;
},
contextMenu(ev, model, index) {
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
}
this.selectRange(index);
}
this.wasLong = false;
},
selectRange(index) {
this.$clipboard.addRange(index, this.photos);

View file

@ -48,6 +48,19 @@
>
<v-icon>edit</v-icon>
</v-btn>
<v-btn
fab
dark
small
:title="labels.private"
color="private"
:disabled="selection.length === 0"
@click.stop="batchPrivate"
v-if="context !== 'archive' && config.settings.library.private"
class="p-photo-clipboard-private"
>
<v-icon>lock</v-icon>
</v-btn>
<v-btn
fab
dark
@ -130,9 +143,9 @@
<p-photo-album-dialog :show="dialog.album" @cancel="dialog.album = false"
@confirm="addToAlbum"></p-photo-album-dialog>
<p-photo-archive-dialog :show="dialog.archive" @cancel="dialog.archive = false"
@confirm="batchArchivePhotos"></p-photo-archive-dialog>
@confirm="batchArchivePhotos"></p-photo-archive-dialog>
<p-photo-share-dialog :show="dialog.share" :selection="selection" :album="album" @cancel="dialog.share = false"
@confirm="dialog.share = false"></p-photo-share-dialog>
@confirm="dialog.share = false"></p-photo-share-dialog>
</div>
</template>
<script>
@ -150,6 +163,7 @@
},
data() {
return {
config: this.$config.values,
expanded: false,
dialog: {
archive: false,
@ -158,6 +172,7 @@
},
labels: {
share: this.$gettext("Share"),
private: this.$gettext("Change private flag"),
edit: this.$gettext("Edit"),
story: this.$gettext("Story"),
addToAlbum: this.$gettext("Add to album"),
@ -182,6 +197,12 @@
Notify.success(this.$gettext("Photos archived"));
this.clearClipboard();
},
batchPrivate() {
Api.post("batch/photos/private", {"photos": this.selection}).then(() => this.onPrivateSaved());
},
onPrivateSaved() {
this.clearClipboard();
},
batchRestorePhotos() {
Api.post("batch/photos/restore", {"photos": this.selection}).then(() => this.onRestored());
},
@ -198,7 +219,7 @@
this.clearClipboard();
},
removeFromAlbum() {
if(!this.album) {
if (!this.album) {
this.$notify.error(this.$gettext("remove failed: unknown album"));
return
}
@ -213,7 +234,7 @@
this.clearClipboard();
},
download() {
if(this.selection.length === 1) {
if (this.selection.length === 1) {
this.onDownload(`/api/v1/photos/${this.selection[0]}/download`);
} else {
Api.post("zip", {"photos": this.selection}).then(r => {

View file

@ -13,9 +13,9 @@
<td style="user-select: none;">
<v-img class="accent lighten-2" style="cursor: pointer" aspect-ratio="1"
:src="props.item.getThumbnailUrl('tile_50')"
v-longclick="longClick"
@contextmenu="contextMenu($event, props.item, props.index)"
@click="onClick($event, props.item, props.index)"
@mousedown="onMouseDown($event, props.index)"
@contextmenu="onContextMenu($event, props.index)"
@click.stop.prevent="onClick($event, props.index)"
>
<v-layout
slot="placeholder"
@ -28,21 +28,18 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="selection.length > 0" :flat="true" :ripple="false"
icon large absolute
class="p-photo-select">
<v-icon v-if="selection.length && $clipboard.has(props.item)" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-btn v-if="selection.length && $clipboard.has(props.item)" :flat="true" :ripple="false"
icon large absolute class="p-photo-select">
<v-icon color="white" class="t-select t-on">check_circle</v-icon>
</v-btn>
</v-img>
</td>
<td class="p-photo-desc p-pointer" @click.exact="openPhoto(props.index)" style="user-select: none;">
<td class="p-photo-desc p-pointer" @click.exact="editPhoto(props.index)" style="user-select: none;">
{{ props.item.PhotoTitle }}
</td>
<td class="p-photo-desc hidden-xs-only" :title="props.item.TakenAt | luxon:format('dd/MM/yyyy HH:mm:ss')">
<td class="p-photo-desc hidden-xs-only" :title="props.item.getDateString()">
<button @click.stop.prevent="editPhoto(props.index)" style="user-select: none;">
{{ props.item.TakenAt | luxon:locale }}
{{ props.item.TakenAt | luxon:locale('DATE_MED') }}
</button>
</td>
<td class="p-photo-desc hidden-sm-and-down" style="user-select: none;">
@ -59,11 +56,16 @@
{{ props.item.getLocation() }}
</span>
</td>
<td>
<td class="text-xs-center">
<v-btn v-if="hidePrivate" class="p-photo-private" icon small flat :ripple="false"
@click.stop.prevent="props.item.togglePrivate()">
<v-icon v-if="props.item.PhotoPrivate" color="secondary-dark">lock</v-icon>
<v-icon v-else color="accent lighten-3">lock_open</v-icon>
</v-btn>
<v-btn class="p-photo-like" icon small flat :ripple="false"
@click.stop.prevent="props.item.toggleLike()">
<v-icon v-if="props.item.PhotoFavorite" color="pink lighten-3">favorite</v-icon>
<v-icon v-else color="accent lighten-4">favorite_border</v-icon>
<v-icon v-else color="accent lighten-3">favorite_border</v-icon>
</v-btn>
</td>
</template>
@ -89,10 +91,14 @@
{text: this.$gettext('Taken'), class: 'hidden-xs-only', value: 'TakenAt'},
{text: this.$gettext('Camera'), class: 'hidden-sm-and-down', value: 'CameraModel'},
{text: this.$gettext('Location'), class: 'hidden-xs-only', value: 'LocLabel'},
{text: this.$gettext('Favorite'), value: 'PhotoFavorite', align: 'left'},
{text: '', value: '', sortable: false, align: 'center'},
],
showLocation: this.$config.settings().features.places,
wasLong: false,
hidePrivate: this.$config.settings().library.private,
mouseDown: {
index: -1,
timeStamp: -1,
},
};
},
watch: {
@ -110,32 +116,36 @@
},
},
methods: {
longClick() {
this.wasLong = true;
},
onClick(ev, model, index) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong || ev.shiftKey) {
onSelect(ev, index) {
if (ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
this.$clipboard.toggle(this.photos[index]);
}
this.wasLong = false;
},
contextMenu(ev, model, index) {
ev.preventDefault();
ev.stopPropagation();
onMouseDown(ev, index) {
this.mouseDown.index = index;
this.mouseDown.timeStamp = ev.timeStamp;
},
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (this.wasLong) {
this.selectRange(index);
if (longClick || this.selection.length > 0) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(this.photos[index]);
}
} else {
this.$clipboard.toggle(model);
this.openPhoto(index, false);
}
},
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
this.selectRange(index);
}
this.wasLong = false;
},
selectRange(index) {
this.$clipboard.addRange(index, this.photos);

View file

@ -23,15 +23,15 @@
>
<v-hover>
<v-card tile slot-scope="{ hover }"
@contextmenu="contextMenu($event, photo, index)"
@contextmenu="onContextMenu($event, index)"
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
:title="photo.PhotoTitle">
<v-img :src="photo.getThumbnailUrl('tile_224')"
aspect-ratio="1"
class="accent lighten-2"
style="cursor: pointer"
v-longclick="longClick"
@click="onClick($event, photo, index)"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-layout
slot="placeholder"
@ -44,38 +44,35 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
icon small absolute
:class="$clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, photo, index)">
<v-btn v-if="hidePrivate && photo.PhotoPrivate"
icon flat small absolute
class="p-photo-private opacity-75">
<v-icon color="white">lock</v-icon>
</v-btn>
<v-btn v-if="hover || selection.length && $clipboard.has(photo)"
icon flat small absolute
:class="selection.length && $clipboard.has(photo) ? 'p-photo-select' : 'p-photo-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.length && $clipboard.has(photo)" color="white"
class="t-select t-on">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3" class="t-select t-off">radio_button_off</v-icon>
</v-btn>
<v-btn :flat="!hover" :ripple="false"
icon small absolute
<v-btn icon flat small absolute
:class="photo.PhotoFavorite ? 'p-photo-like opacity-75' : 'p-photo-like opacity-50'"
@click.stop.prevent="photo.toggleLike()">
<v-icon v-if="photo.PhotoFavorite" color="white" class="t-like t-on">favorite</v-icon>
<v-icon v-else color="accent lighten-3" class="t-like t-off">favorite_border</v-icon>
</v-btn>
<v-btn v-if="photo.Files.length > 1" :flat="!hover" :ripple="false"
icon small absolute class="p-photo-merged opacity-75"
<v-btn v-if="photo.Files.length > 1"
icon flat small absolute class="p-photo-merged opacity-75"
@click.stop.prevent="openPhoto(index, true)">
<v-icon color="white" class="action-burst">burst_mode</v-icon>
</v-btn>
<!-- v-btn v-if="hover" :flat="!hover" :ripple="false"
icon small absolute
class="p-photo-edit"
@click.stop.prevent="editPhoto(index)">
<v-icon color="white">edit</v-icon>
</v-btn -->
</v-img>
</v-card>
</v-hover>
</v-flex>
@ -94,51 +91,44 @@
},
data() {
return {
wasLong: false,
hidePrivate: this.$config.settings().library.private,
mouseDown: {
index: -1,
timeStamp: -1,
},
};
},
methods: {
longClick() {
this.wasLong = true;
},
onSelect(ev, model, index) {
onSelect(ev, index) {
if (ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
this.$clipboard.toggle(this.photos[index]);
}
this.wasLong = false;
},
onClick(ev, model, index) {
if (this.wasLong || this.selection.length > 0) {
ev.preventDefault();
ev.stopPropagation();
onMouseDown(ev, index) {
this.mouseDown.index = index;
this.mouseDown.timeStamp = ev.timeStamp;
},
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (this.wasLong || ev.shiftKey) {
if (longClick || this.selection.length > 0) {
if (longClick || ev.shiftKey) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
this.$clipboard.toggle(this.photos[index]);
}
} else {
this.openPhoto(index, false);
}
this.wasLong = false;
},
contextMenu(ev, model, index) {
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong) {
this.selectRange(index);
} else {
this.$clipboard.toggle(model);
}
this.selectRange(index);
}
this.wasLong = false;
},
selectRange(index) {
this.$clipboard.addRange(index, this.photos);

View file

@ -169,8 +169,8 @@
{value: 'imported', text: this.$gettext('Recently imported')},
{value: 'newest', text: this.$gettext('Newest first')},
{value: 'oldest', text: this.$gettext('Oldest first')},
{value: 'similar', text: this.$gettext('Similar')},
{value: 'relevance', text: this.$gettext('Relevance')},
{value: 'similar', text: this.$gettext('Group by similarity')},
{value: 'relevance', text: this.$gettext('Most relevant')},
],
},
labels: {

View file

@ -16,6 +16,12 @@
left: 7px;
}
#photoprism .p-photo-mosaic .p-photo-private,
#photoprism .p-photo-cards .p-photo-private {
top: 4px;
right: 4px;
}
#photoprism .p-photo-mosaic .p-photo-edit,
#photoprism .p-photo-cards .p-photo-edit {
top: 4px;

View file

@ -6,7 +6,9 @@
<v-btn icon dark @click.stop="close">
<v-icon>close</v-icon>
</v-btn>
<v-toolbar-title>{{ title }}</v-toolbar-title>
<v-toolbar-title>{{ title }}
<v-icon v-if="isPrivate" title="Private">lock</v-icon>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items v-if="selection.length > 1">
<v-btn icon disabled @click.stop="prev" :disabled="selected < 1">
@ -84,6 +86,13 @@
this.$gettext("Edit Photo");
},
isPrivate: function () {
if (this.model && this.model.PhotoPrivate && this.$config.settings().library.private) {
return this.model.PhotoPrivate
}
return false;
},
},
data() {
return {

View file

@ -355,7 +355,8 @@
</v-btn>
<v-btn color="secondary-dark" depressed dark @click.stop="save(false)"
class="p-photo-dialog-confirm">
<span>Apply</span>
<span v-if="config.settings.library.review && model.PhotoQuality < 3">Approve</span>
<span v-else>Apply</span>
</v-btn>
<v-btn color="secondary-dark" depressed dark @click.stop="save(true)"
class="p-photo-dialog-confirm hidden-xs-only">

View file

@ -27,7 +27,6 @@ class Photo extends RestModel {
PhotoFavorite: false,
PhotoStory: false,
PhotoPrivate: false,
PhotoNSFW: false,
PhotoResolution: 0,
PhotoQuality: 0,
PhotoLat: 0.0,
@ -86,13 +85,13 @@ class Photo extends RestModel {
getColor() {
switch (this.PhotoColor) {
case "brown":
case "black":
case "white":
case "grey":
return "grey lighten-2";
default:
return this.PhotoColor + " lighten-4";
case "brown":
case "black":
case "white":
case "grey":
return "grey lighten-2";
default:
return this.PhotoColor + " lighten-4";
}
}
@ -242,6 +241,12 @@ class Photo extends RestModel {
}
}
togglePrivate() {
this.PhotoPrivate = !this.PhotoPrivate;
return Api.put(this.getEntityResource(), {PhotoPrivate: this.PhotoPrivate});
}
setPrimary(fileUUID) {
return Api.post(this.getEntityResource() + "/primary/" + fileUUID).then((r) => Promise.resolve(this.setValues(r.data)));
}

View file

@ -92,10 +92,10 @@ class Thumb extends Model {
if (!p.Files) return;
p.Files.forEach((f) => {
if (f.FileType === "jpg") {
result.push(this.fromFile(p, f));
}
if (f.FileType === "jpg") {
result.push(this.fromFile(p, f));
}
}
);
});

View file

@ -124,7 +124,7 @@
if (photo.LocationID) {
this.$router.push({name: "place", params: {q: "s2:" + photo.LocationID}});
} else if (photo.PlaceID && photo.PlaceID !== "-") {
} else if (photo.PlaceID.length > 3) {
this.$router.push({name: "place", params: {q: "s2:" + photo.PlaceID}});
}
},
@ -335,7 +335,7 @@
if (model) {
for (let key in values) {
if (values.hasOwnProperty(key)) {
if (values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
model[key] = values[key];
}
}

View file

@ -61,15 +61,15 @@
<v-hover>
<v-card tile class="accent lighten-3"
slot-scope="{ hover }"
@contextmenu="contextMenu($event, album, index)"
@contextmenu="onContextMenu($event, index)"
:dark="selection.includes(album.AlbumUUID)"
:class="selection.includes(album.AlbumUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'album', params: {uuid: album.AlbumUUID, slug: album.AlbumSlug}}"
>
<v-img
:src="album.getThumbnailUrl('tile_500')"
v-longclick="longClick"
@click="onClick($event, album, index)"
@mousedown="onMouseDown($event, index)"
@click="onClick($event, index)"
aspect-ratio="1"
class="accent lighten-2"
>
@ -84,10 +84,10 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
<v-btn v-if="hover || selection.includes(album.AlbumUUID)" :flat="!hover" :ripple="false"
icon large absolute
:class="selection.includes(album.AlbumUUID) ? 'p-album-select' : 'p-album-select opacity-50'"
@click.stop.prevent="onSelect($event, album, index)">
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.includes(album.AlbumUUID)" color="white">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3">radio_button_off</v-icon>
@ -183,7 +183,10 @@
search: this.$gettext("Search"),
name: this.$gettext("Album Name"),
},
wasLong: false,
mouseDown: {
index: -1,
timeStamp: -1,
},
lastId: "",
};
},
@ -213,45 +216,40 @@
return (rangeEnd - rangeStart) + 1;
},
longClick() {
this.wasLong = true;
},
onSelect(ev, model, index) {
onSelect(ev, index) {
if (ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
this.toggleSelection(this.results[index].getId());
}
this.wasLong = false;
},
onClick(ev, model, index) {
if (this.wasLong || this.selection.length > 0) {
onMouseDown(ev, index) {
this.mouseDown.index = index;
this.mouseDown.timeStamp = ev.timeStamp;
},
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong || ev.shiftKey) {
if (longClick || ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
this.toggleSelection(this.results[index].getId());
}
}
this.wasLong = false;
},
contextMenu(ev, model, index) {
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong) {
if(this.results[index]) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
}
}
this.wasLong = false;
},
clearQuery() {
this.filter.q = '';

View file

@ -64,14 +64,14 @@
<v-hover>
<v-card tile class="accent lighten-3"
slot-scope="{ hover }"
@contextmenu="contextMenu($event, label, index)"
@contextmenu="onContextMenu($event, index)"
:dark="selection.includes(label.LabelUUID)"
:class="selection.includes(label.LabelUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'photos', query: {q: 'label:' + (label.CustomSlug ? label.CustomSlug : label.LabelSlug)}}">
<v-img
:src="label.getThumbnailUrl('tile_500')"
v-longclick="longClick"
@click="onClick($event, label, index)"
@mousedown="onMouseDown($event, index)"
@click="onClick($event, index)"
aspect-ratio="1"
class="accent lighten-2"
>
@ -86,10 +86,10 @@
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
<v-btn v-if="hover || selection.includes(label.LabelUUID)" :flat="!hover" :ripple="false"
icon large absolute
:class="selection.includes(label.LabelUUID) ? 'p-label-select' : 'p-label-select opacity-50'"
@click.stop.prevent="onSelect($event, label, index)">
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.includes(label.LabelUUID)" color="white">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3">radio_button_off</v-icon>
@ -185,7 +185,10 @@
name: this.$gettext("Label Name"),
},
titleRule: v => v.length <= 25 || this.$gettext("Name too long"),
wasLong: false,
mouseDown: {
index: -1,
timeStamp: -1,
},
lastId: "",
};
},
@ -215,45 +218,40 @@
return (rangeEnd - rangeStart) + 1;
},
longClick() {
this.wasLong = true;
},
onSelect(ev, model, index) {
onSelect(ev, index) {
if (ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
this.toggleSelection(this.results[index].getId());
}
this.wasLong = false;
},
onClick(ev, model, index) {
if (this.wasLong || this.selection.length > 0) {
onMouseDown(ev, index) {
this.mouseDown.index = index;
this.mouseDown.timeStamp = ev.timeStamp;
},
onClick(ev, index) {
let longClick = (this.mouseDown.index === index && ev.timeStamp - this.mouseDown.timeStamp > 400);
if (longClick || this.selection.length > 0) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong || ev.shiftKey) {
if (longClick || ev.shiftKey) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
this.toggleSelection(this.results[index].getId());
}
}
this.wasLong = false;
},
contextMenu(ev, model, index) {
onContextMenu(ev, index) {
if (this.$isMobile) {
ev.preventDefault();
ev.stopPropagation();
if (this.wasLong) {
if(this.results[index]) {
this.selectRange(index, this.results);
} else {
this.toggleSelection(model.getId());
}
}
this.wasLong = false;
},
onSave(label) {
label.update();

View file

@ -17,9 +17,10 @@
<v-layout wrap align-top class="pb-3">
<v-flex xs12 sm6 lg4 class="px-2 pb-2 pt-2">
<v-checkbox
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="options.raw"
v-model="settings.library.raw"
color="secondary-dark"
:label="labels.raw"
hint="RAWs need to be converted to JPEG so that they can be displayed in a browser. You can also do this manually."
@ -31,9 +32,10 @@
<v-flex xs12 sm6 lg4 class="px-2 pb-2 pt-2">
<v-checkbox
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="options.thumbs"
v-model="settings.library.thumbs"
color="secondary-dark"
:label="labels.thumbs"
hint="Pre-render thumbnails if not done already. On-demand rendering saves storage but requires a powerful CPU."
@ -45,9 +47,10 @@
<v-flex xs12 sm6 lg4 class="px-2 pb-2 pt-2">
<v-checkbox
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="options.rescan"
v-model="settings.library.rescan"
color="secondary-dark"
:label="labels.rescan"
hint="Re-index all originals, including already indexed and unchanged files."
@ -88,13 +91,13 @@
import Axios from "axios";
import Notify from "common/notify";
import Event from "pubsub-js";
import Settings from "../../model/settings";
export default {
name: 'p-tab-index',
data() {
let settings = this.$config.settings();
return {
settings: new Settings(this.$config.settings()),
readonly: this.$config.getValue("readonly"),
started: false,
busy: false,
@ -103,11 +106,6 @@
action: "",
fileName: "",
source: null,
options: {
rescan: settings.library.rescan,
thumbs: settings.library.thumbs,
raw: settings.library.raw,
},
labels: {
rescan: this.$gettext("Complete rescan"),
thumbs: this.$gettext("Create thumbnails"),
@ -116,6 +114,9 @@
}
},
methods: {
onChange() {
this.settings.save();
},
submit() {
// DO NOTHING
},
@ -132,7 +133,7 @@
const ctx = this;
Notify.blockUI();
Api.post('index', this.options, {cancelToken: this.source.token}).then(function () {
Api.post('index', this.settings.library, {cancelToken: this.source.token}).then(function () {
Notify.unblockUI();
ctx.busy = false;
ctx.completed = 100;

View file

@ -84,7 +84,16 @@
order: order,
q: q,
};
const settings = {view: view};
const settings = this.$config.settings();
if (settings.library.private) {
filter.public = true;
}
if (settings.library.review) {
filter.quality = 3;
}
return {
subscriptions: [],
@ -96,7 +105,7 @@
offset: 0,
page: 0,
selection: this.$clipboard.selection,
settings: settings,
settings: {view: view},
filter: filter,
lastFilter: {},
routeName: routeName,
@ -152,7 +161,7 @@
if (photo.LocationID) {
this.$router.push({name: "place", params: {q: "s2:" + photo.LocationID}});
} else if (photo.PlaceID && photo.PlaceID !== "-") {
} else if (photo.PlaceID.length > 3) {
this.$router.push({name: "place", params: {q: "s2:" + photo.PlaceID}});
}
},
@ -323,7 +332,7 @@
if (model) {
for (let key in values) {
if (values.hasOwnProperty(key)) {
if (values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
model[key] = values[key];
}
}

View file

@ -2,7 +2,7 @@
<div class="p-tab p-settings-general">
<v-form lazy-validation dense
ref="form" class="p-form-settings" accept-charset="UTF-8"
@submit.prevent="save">
@submit.prevent="onChange">
<v-card flat tile class="mt-0 px-1 application">
<v-card-title primary-title class="pb-0">
<h3 class="body-2 mb-0">Library</h3>
@ -12,28 +12,14 @@
<v-layout wrap align-top>
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
class="ma-0 pa-0"
v-model="settings.library.raw"
color="secondary-dark"
:label="labels.raw"
hint="RAWs need to be converted to JPEG so that they can be displayed in a browser. You can also do this manually."
prepend-icon="photo_camera"
persistent-hint
>
</v-checkbox>
</v-flex>
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.library.thumbs"
v-model="settings.library.private"
color="secondary-dark"
:label="labels.thumbs"
hint="Pre-render thumbnails if not done already. On-demand rendering saves storage but requires a powerful CPU."
prepend-icon="photo_size_select_large"
:label="labels.private"
hint="Exclude photos and videos marked as private from search results by default."
prepend-icon="lock"
persistent-hint
>
</v-checkbox>
@ -41,7 +27,23 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.library.review"
color="secondary-dark"
:label="labels.review"
hint="Low-quality photos and videos require a review before they appear in search results."
prepend-icon="remove_red_eye"
persistent-hint
>
</v-checkbox>
</v-flex>
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.library.group"
color="secondary-dark"
@ -55,7 +57,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.library.move"
color="secondary-dark"
@ -80,7 +83,8 @@
<v-layout wrap align-top>
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.places"
color="secondary-dark"
@ -94,7 +98,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.labels"
color="secondary-dark"
@ -108,7 +113,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.import"
color="secondary-dark"
@ -122,7 +128,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.archive"
color="secondary-dark"
@ -136,7 +143,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.upload"
color="secondary-dark"
@ -150,7 +158,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.download"
color="secondary-dark"
@ -164,7 +173,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.edit"
color="secondary-dark"
@ -178,7 +188,8 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
@change="save"
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.share"
color="secondary-dark"
@ -202,7 +213,8 @@
<v-layout wrap align-top>
<v-flex xs12 sm6 class="px-2 pb-2">
<v-select
@change="save"
@change="onChange"
:disabled="busy"
:items="options.themes"
:label="labels.theme"
color="secondary-dark"
@ -214,7 +226,8 @@
<v-flex xs12 sm6 class="px-2 pb-2">
<v-select
@change="save"
@change="onChange"
:disabled="busy"
:items="options.languages"
:label="labels.language"
color="secondary-dark"
@ -236,7 +249,8 @@
<v-layout wrap align-top>
<v-flex xs12 sm6 class="px-2 pb-2">
<v-select
@change="save"
@change="onChange"
:disabled="busy"
:items="options.mapsStyle"
:label="labels.mapsStyle"
color="secondary-dark"
@ -248,7 +262,8 @@
<v-flex xs12 sm6 class="px-2 pb-2">
<v-select
@change="save"
@change="onChange"
:disabled="busy"
:items="options.mapsAnimate"
:label="labels.mapsAnimate"
color="secondary-dark"
@ -293,7 +308,7 @@
data() {
return {
readonly: this.$config.getValue("readonly"),
settings: new Settings(this.$config.values.settings),
settings: new Settings(this.$config.settings()),
options: options,
labels: {
language: this.$gettext("Language"),
@ -305,19 +320,24 @@
raw: this.$gettext("Convert RAW files"),
move: this.$gettext("Remove imported files"),
group: this.$gettext("Group related files"),
private: this.$gettext("Hide private content"),
review: this.$gettext("Apply quality filter"),
},
busy: false,
};
},
methods: {
load() {
this.settings.load();
},
save() {
onChange() {
const reload = this.settings.changed("language");
this.settings.save().then((s) => {
this.$config.updateSettings(s.getValues(), this.$vuetify);
if (reload) {
this.busy = true;
}
this.settings.save().then((s) => {
if (reload) {
this.$notify.info(this.$gettext("Reloading..."));
this.$notify.blockUI();
@ -325,7 +345,7 @@
} else {
this.$notify.success(this.$gettext("Settings saved"));
}
})
}).finally(() => this.busy = false)
},
},
created() {

View file

@ -51,6 +51,7 @@
created() {
window.addEventListener('touchstart', (e) => this.onTouchStart(e), {passive: true});
window.addEventListener('touchmove', (e) => this.onTouchMove(e), {passive: true});
this.$config.setVuetify(this.$vuetify);
},
destroyed() {
window.removeEventListener('touchstart', (e) => this.onTouchStart(e), false);

View file

@ -15,6 +15,7 @@
"restore": "#64B5F6",
"album": "#FFAB40",
"download": "#07BD9F",
"private": "#00B8D4",
"edit": "#48BCD6",
"share": "#0070A0",
"love": "#EF5350",
@ -38,6 +39,7 @@
"restore": "#64B5F6",
"album": "#FFAB00",
"download": "#00BFA5",
"private": "#00B8D4",
"edit": "#00B8D4",
"share": "#9575CD",
"love": "#EF5350",
@ -61,6 +63,7 @@
"restore": "#64B5F6",
"album": "#FFAB00",
"download": "#00BFA5",
"private": "#00B8D4",
"edit": "#00B8D4",
"share": "#9575CD",
"love": "#EF5350",
@ -84,6 +87,7 @@
"restore": "#64B5F6",
"album": "#FFAB00",
"download": "#00BFA5",
"private": "#00B8D4",
"edit": "#00B8D4",
"share": "#9575CD",
"love": "#EF5350",
@ -107,6 +111,7 @@
"restore": "#64B5F6",
"album": "#FFAB00",
"download": "#00BFA5",
"private": "#00B8D4",
"edit": "#00B8D4",
"share": "#9575CD",
"love": "#EF5350",
@ -130,6 +135,7 @@
"restore": "#64B5F6",
"album": "#FFAB00",
"download": "#00BFA5",
"private": "#00B8D4",
"edit": "#00B8D4",
"share": "#9575CD",
"love": "#EF5350",
@ -154,6 +160,7 @@
"restore": "#64B5F6",
"album": "#FFAB40",
"download": "#07BD9F",
"private": "#48BCD6",
"edit": "#48BCD6",
"share": "#0070A0",
"terminal": "#333333",

View file

@ -51,6 +51,20 @@ export default [
meta: {title: "Favorites", auth: true},
props: {staticFilter: {favorites: true}},
},
{
name: "review",
path: "/review",
component: Photos,
meta: {title: "Review", auth: true},
props: {staticFilter: {review: true}},
},
{
name: "private",
path: "/private",
component: Photos,
meta: {title: "Private", auth: true},
props: {staticFilter: {private: true}},
},
{
name: "archive",
path: "/archive",

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
@ -157,7 +158,18 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
db := conf.Db()
db.Model(entity.Photo{}).Where("photo_uuid IN (?)", f.Photos).UpdateColumn("photo_private", gorm.Expr("IF (`photo_private`, 0, 1)"))
err := db.Model(entity.Photo{}).Where("photo_uuid IN (?)", f.Photos).UpdateColumn("photo_private", gorm.Expr("IF (`photo_private`, 0, 1)")).Error
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrSaveFailed)
return
}
q := query.New(db)
if entities, err := q.PhotoSelection(f); err == nil {
event.EntitiesUpdated("photos", entities)
}
elapsed := time.Since(start)

View file

@ -56,12 +56,12 @@ func GetGeo(router *gin.RouterGroup, conf *config.Config) {
}
for _, p := range photos {
bboxMin(0, p.PhotoLng)
bboxMin(1, p.PhotoLat)
bboxMax(2, p.PhotoLng)
bboxMax(3, p.PhotoLat)
bboxMin(0, p.Lng())
bboxMin(1, p.Lat())
bboxMax(2, p.Lng())
bboxMax(3, p.Lat())
feat := geojson.NewPointFeature([]float64{p.PhotoLng, p.PhotoLat})
feat := geojson.NewPointFeature([]float64{p.Lng(), p.Lat()})
feat.ID = p.ID
feat.Properties = gin.H{
"PhotoUUID": p.PhotoUUID,

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestGetPhoto(t *testing.T) {
@ -13,7 +14,8 @@ func TestGetPhoto(t *testing.T) {
GetPhoto(router, ctx)
result := PerformRequest(app, "GET", "/api/v1/photos/654")
assert.Equal(t, http.StatusOK, result.Code)
assert.Contains(t, result.Body.String(), "\"PhotoLat\":48.519235")
val := gjson.Get(result.Body.String(), "PhotoLat")
assert.Equal(t, "48.519234", val.String())
})
t.Run("search for not existing photo", func(t *testing.T) {
app, router, ctx := NewApiTest()

View file

@ -46,6 +46,6 @@ func SaveSettings(router *gin.RouterGroup, conf *config.Config) {
event.Publish("config.updated", event.Data(conf.ClientConfig()))
log.Infof("settings saved")
c.JSON(http.StatusOK, gin.H{"message": "saved"})
c.JSON(http.StatusOK, s)
})
}

View file

@ -167,15 +167,15 @@ func (c *Config) ClientConfig() ClientConfig {
db.Model(&entity.Country{}).
Select("id, country_name").
Order("country_name").
Order("country_slug").
Scan(&countries)
db.Where("deleted_at IS NULL").
Limit(1000).Order("camera_model").
Limit(10000).Order("camera_slug").
Find(&cameras)
db.Where("deleted_at IS NULL").
Limit(1000).Order("lens_model").
Limit(10000).Order("lens_slug").
Find(&lenses)
db.Where("deleted_at IS NULL AND album_favorite = 1").

View file

@ -85,6 +85,8 @@ func (c *Config) MigrateDb() {
entity.CreateUnknownPlace(db)
entity.CreateUnknownCountry(db)
entity.CreateUnknownCamera(db)
entity.CreateUnknownLens(db)
}
// DropTables drops all tables in the currently configured database (be careful!).

View file

@ -222,12 +222,12 @@ var GlobalFlags = []cli.Flag{
},
cli.BoolFlag{
Name: "detect-nsfw",
Usage: "flag photos that may be offensive",
Usage: "flag photos as private that may be offensive",
EnvVar: "PHOTOPRISM_DETECT_NSFW",
},
cli.BoolFlag{
Name: "upload-nsfw",
Usage: "allow uploads that may contain offensive content",
Usage: "allow uploads that may be offensive",
EnvVar: "PHOTOPRISM_UPLOAD_NSFW",
},
cli.StringFlag{

View file

@ -20,11 +20,13 @@ type MapsSettings struct {
}
type LibrarySettings struct {
CompleteRescan bool `json:"rescan" yaml:"rescan"`
ConvertRaw bool `json:"raw" yaml:"raw"`
CreateThumbs bool `json:"thumbs" yaml:"thumbs"`
GroupRelated bool `json:"group" yaml:"group"`
CompleteRescan bool `json:"rescan" yaml:"rescan"`
MoveImported bool `json:"move" yaml:"move"`
HidePrivate bool `json:"private" yaml:"private"`
RequireReview bool `json:"review" yaml:"review"`
GroupRelated bool `json:"group" yaml:"group"`
}
type FeatureSettings struct {
@ -70,8 +72,10 @@ func NewSettings() *Settings {
CompleteRescan: false,
ConvertRaw: false,
CreateThumbs: false,
GroupRelated: true,
MoveImported: false,
GroupRelated: true,
RequireReview: true,
HidePrivate: true,
},
}
}

View file

@ -13,8 +13,10 @@ features:
edit: true
share: true
library:
rescan: false
raw: false
thumbs: false
group: true
rescan: false
move: false
private: true
review: true
group: true

View file

@ -24,12 +24,23 @@ type Camera struct {
DeletedAt *time.Time `sql:"index"`
}
var UnknownCamera = Camera{
CameraModel: "Unknown",
CameraMake: "",
CameraSlug: "zz",
}
// CreateUnknownCamera initializes the database with an unknown camera if not exists
func CreateUnknownCamera(db *gorm.DB) {
UnknownCamera.FirstOrCreate(db)
}
// NewCamera creates a camera entity from a model name and a make name.
func NewCamera(modelName string, makeName string) *Camera {
makeName = strings.TrimSpace(makeName)
if modelName == "" {
modelName = "Unknown"
return &UnknownCamera
} else if strings.HasPrefix(modelName, makeName) {
modelName = strings.TrimSpace(modelName[len(makeName):])
}

View file

@ -7,15 +7,10 @@ import (
)
func TestNewCamera(t *testing.T) {
t.Run("model Unknown make Nikon", func(t *testing.T) {
t.Run("unknown camera", func(t *testing.T) {
camera := NewCamera("", "Nikon")
expected := &Camera{
CameraModel: "Unknown",
CameraMake: "Nikon",
CameraSlug: "nikon-unknown",
}
assert.Equal(t, expected, camera)
assert.Equal(t, &UnknownCamera, camera)
})
t.Run("model EOS 6D make Canon", func(t *testing.T) {
camera := NewCamera("EOS 6D", "Canon")
@ -50,12 +45,7 @@ func TestNewCamera(t *testing.T) {
t.Run("model Unknown make Unknown", func(t *testing.T) {
camera := NewCamera("", "")
expected := &Camera{
CameraModel: "Unknown",
CameraMake: "",
CameraSlug: "unknown",
}
assert.Equal(t, expected, camera)
assert.Equal(t, &UnknownCamera, camera)
})
}

View file

@ -16,7 +16,7 @@ var altCountryNames = map[string]string{
// Country represents a country location, used for labeling photos.
type Country struct {
ID string `gorm:"primary_key"`
ID string `gorm:"type:varbinary(2);primary_key"`
CountrySlug string `gorm:"type:varbinary(128);unique_index;"`
CountryName string
CountryDescription string `gorm:"type:text;"`
@ -26,8 +26,12 @@ type Country struct {
New bool `gorm:"-"`
}
// UnknownCountry is the default country
var UnknownCountry = NewCountry("zz", maps.CountryNames["zz"])
// UnknownCountry is defined here to use it as a default
var UnknownCountry = Country{
ID: "zz",
CountryName: maps.CountryNames["zz"],
CountrySlug: "zz",
}
// CreateUnknownCountry is used to initialize the database with the default country
func CreateUnknownCountry(db *gorm.DB) {
@ -37,7 +41,7 @@ func CreateUnknownCountry(db *gorm.DB) {
// NewCountry creates a new country, with default country code if not provided
func NewCountry(countryCode string, countryName string) *Country {
if countryCode == "" {
countryCode = "zz"
return &UnknownCountry
}
if altName, ok := altCountryNames[countryName]; ok {

View file

@ -17,9 +17,9 @@ type Event struct {
EventNotes string `gorm:"type:text;"`
EventBegin time.Time `gorm:"type:datetime;"`
EventEnd time.Time `gorm:"type:datetime;"`
EventLat float64
EventLng float64
EventDist float64
EventLat float32 `gorm:"type:FLOAT;"`
EventLng float32 `gorm:"type:FLOAT;"`
EventDist float32 `gorm:"type:FLOAT;"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"`

View file

@ -33,7 +33,7 @@ type File struct {
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
FileAspectRatio float32 `gorm:"type:FLOAT;"`
FileMainColor string `gorm:"type:varbinary(16);index;"`
FileColors string `gorm:"type:binary(9);"`
FileLuminance string `gorm:"type:binary(9);"`

View file

@ -24,6 +24,17 @@ type Lens struct {
DeletedAt *time.Time `sql:"index"`
}
var UnknownLens = Lens{
LensModel: "Unknown",
LensMake: "",
LensSlug: "zz",
}
// CreateUnknownLens initializes the database with an unknown lens if not exists
func CreateUnknownLens(db *gorm.DB) {
UnknownLens.FirstOrCreate(db)
}
// TableName returns Lens table identifier "lens"
func (Lens) TableName() string {
return "lenses"
@ -33,13 +44,12 @@ func (Lens) TableName() string {
func NewLens(modelName string, makeName string) *Lens {
modelName = strings.TrimSpace(modelName)
makeName = strings.TrimSpace(makeName)
lensSlug := slug.MakeLang(modelName, "en")
if modelName == "" {
modelName = "Unknown"
return &UnknownLens
}
lensSlug := slug.MakeLang(modelName, "en")
result := &Lens{
LensModel: modelName,
LensMake: makeName,
@ -49,7 +59,7 @@ func NewLens(modelName string, makeName string) *Lens {
return result
}
// FirstOrCreate checks wether the lens already exists in the database
// FirstOrCreate checks if the lens already exists in the database
func (m *Lens) FirstOrCreate(db *gorm.DB) *Lens {
mutex.Db.Lock()
defer mutex.Db.Unlock()

View file

@ -17,7 +17,8 @@ func TestNewLens(t *testing.T) {
lens := NewLens("", "")
assert.Equal(t, "Unknown", lens.LensModel)
assert.Equal(t, "", lens.LensMake)
assert.Equal(t, "unknown", lens.LensSlug)
assert.Equal(t, "zz", lens.LensSlug)
assert.Equal(t, &UnknownLens, lens)
})
}

View file

@ -23,18 +23,17 @@ type Location struct {
}
// NewLocation creates a location using a token extracted from coordinate
func NewLocation(lat, lng float64) *Location {
func NewLocation(lat, lng float32) *Location {
result := &Location{}
result.ID = s2.Token(lat, lng)
result.ID = s2.Token(float64(lat), float64(lng))
return result
}
// Find gets the location using either the db or the api if not in the db
func (m *Location) Find(db *gorm.DB, api string) error {
if err := db.First(m, "id = ?", m.ID).Error; err == nil {
m.Place = FindPlace(m.PlaceID, db)
if err := db.Preload("Place").First(m, "id = ?", m.ID).Error; err == nil {
return nil
}
@ -46,14 +45,16 @@ func (m *Location) Find(db *gorm.DB, api string) error {
return err
}
m.Place = FindPlaceByLabel(l.ID, l.LocLabel, db)
if m.Place.NoID() {
m.Place.ID = l.ID
m.Place.LocLabel = l.LocLabel
m.Place.LocCity = l.LocCity
m.Place.LocState = l.LocState
m.Place.LocCountry = l.LocCountry
if place := FindPlaceByLabel(l.ID, l.LocLabel, db); place != nil {
m.Place = place
} else {
m.Place = &Place{
ID: l.ID,
LocLabel: l.LocLabel,
LocCity: l.LocCity,
LocState: l.LocState,
LocCountry: l.LocCountry,
}
}
m.LocName = l.LocName
@ -62,10 +63,7 @@ func (m *Location) Find(db *gorm.DB, api string) error {
if err := db.Create(m).Error; err == nil {
return nil
} else if err := db.First(m, "id = ?", m.ID).Error; err == nil {
// avoid mutex by trying again to find location
m.Place = FindPlace(m.PlaceID, db)
} else {
} else if err := db.Preload("Place").First(m, "id = ?", m.ID).Error; err != nil {
return err
}

View file

@ -22,30 +22,29 @@ type Photo struct {
TakenSrc string `gorm:"type:varbinary(8);" json:"TakenSrc"`
PhotoUUID string `gorm:"type:varbinary(36);unique_index;index:idx_photos_taken_uuid;"`
PhotoPath string `gorm:"type:varbinary(512);index;"`
PhotoName string `gorm:"type:varbinary(256);"`
PhotoTitle string `json:"PhotoTitle"`
PhotoName string `gorm:"type:varbinary(255);"`
PhotoTitle string `gorm:"type:varchar(200);" json:"PhotoTitle"`
TitleSrc string `gorm:"type:varbinary(8);" json:"TitleSrc"`
PhotoQuality int `gorm:"type:SMALLINT" json:"PhotoQuality"`
PhotoResolution int `gorm:"type:SMALLINT" json:"PhotoResolution"`
PhotoFavorite bool `json:"PhotoFavorite"`
PhotoPrivate bool `json:"PhotoPrivate"`
PhotoStory bool `json:"PhotoStory"`
PhotoNSFW bool `json:"PhotoNSFW"`
PhotoLat float64 `gorm:"index;" json:"PhotoLat"`
PhotoLng float64 `gorm:"index;" json:"PhotoLng"`
PhotoLat float32 `gorm:"type:FLOAT;index;" json:"PhotoLat"`
PhotoLng float32 `gorm:"type:FLOAT;index;" json:"PhotoLng"`
PhotoAltitude int `json:"PhotoAltitude"`
PhotoIso int `json:"PhotoIso"`
PhotoFocalLength int `json:"PhotoFocalLength"`
PhotoFNumber float64 `json:"PhotoFNumber"`
PhotoFNumber float32 `gorm:"type:FLOAT;" json:"PhotoFNumber"`
PhotoExposure string `gorm:"type:varbinary(64);" json:"PhotoExposure"`
CameraID uint `gorm:"index:idx_photos_camera_lens;" json:"CameraID"`
CameraSerial string `gorm:"type:varbinary(128);" json:"CameraSerial"`
CameraSrc string `gorm:"type:varbinary(8);" json:"CameraSrc"`
LensID uint `gorm:"index:idx_photos_camera_lens;" json:"LensID"`
PlaceID string `gorm:"type:varbinary(16);index;" json:"PlaceID"`
PlaceID string `gorm:"type:varbinary(16);index;default:'zz'" json:"PlaceID"`
LocationID string `gorm:"type:varbinary(16);index;" json:"LocationID"`
LocationSrc string `gorm:"type:varbinary(8);" json:"LocationSrc"`
PhotoCountry string `gorm:"index:idx_photos_country_year_month;" json:"PhotoCountry"`
PhotoCountry string `gorm:"type:varbinary(2);index:idx_photos_country_year_month;default:'zz'" json:"PhotoCountry"`
PhotoYear int `gorm:"index:idx_photos_country_year_month;"`
PhotoMonth int `gorm:"index:idx_photos_country_year_month;"`
TimeZone string `gorm:"type:varbinary(64);" json:"TimeZone"`
@ -63,6 +62,7 @@ type Photo struct {
Labels []PhotoLabel
CreatedAt time.Time
UpdatedAt time.Time
EditedAt *time.Time
DeletedAt *time.Time `sql:"index"`
}
@ -101,6 +101,8 @@ func SavePhotoForm(model Photo, form form.Photo, db *gorm.DB, geoApi string) err
log.Warnf("%s (%s)", err.Error(), model.PhotoUUID)
}
edited := time.Now().UTC()
model.EditedAt = &edited
model.PhotoQuality = model.QualityScore()
return db.Unscoped().Save(&model).Error
@ -286,12 +288,12 @@ func (m *Photo) NoLatLng() bool {
// NoPlace checks if the photo has no Place
func (m *Photo) NoPlace() bool {
return len(m.PlaceID) < 2
return m.PlaceID == "" || m.PlaceID == UnknownPlace.ID
}
// HasPlace checks if the photo has a Place
func (m *Photo) HasPlace() bool {
return len(m.PlaceID) >= 2
return !m.NoPlace()
}
// NoTitle checks if the photo has no Title

View file

@ -15,8 +15,8 @@ func (m *Photo) GetTimeZone() string {
if m.HasLatLng() {
zones, err := tz.GetZone(tz.Point{
Lat: m.PhotoLat,
Lon: m.PhotoLng,
Lat: float64(m.PhotoLat),
Lon: float64(m.PhotoLng),
})
if err == nil && len(zones) > 0 {
@ -84,7 +84,7 @@ func (m *Photo) UpdateLocation(db *gorm.DB, geoApi string) (keywords []string, l
} else {
log.Warn(err)
m.Place = UnknownPlace
m.Place = &UnknownPlace
m.PlaceID = UnknownPlace.ID
}

View file

@ -2,6 +2,7 @@ package entity
import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -12,6 +13,11 @@ var QualityBlacklist = map[string]bool{
"info": true,
}
var (
year2008 = time.Date(2008, 1, 1, 0, 0, 0, 0, time.UTC)
year2012 = time.Date(2012, 1, 1, 0, 0, 0, 0, time.UTC)
)
// QualityScore returns a score based on photo properties like size and metadata.
func (m *Photo) QualityScore() (score int) {
if m.PhotoFavorite {
@ -26,7 +32,11 @@ func (m *Photo) QualityScore() (score int) {
score++
}
if m.PhotoResolution >= 2 {
if m.TakenAt.Before(year2008) {
score++
} else if m.TakenAt.Before(year2012) && m.PhotoResolution >= 1 {
score++
} else if m.PhotoResolution >= 2 {
score++
}
@ -49,5 +59,9 @@ func (m *Photo) QualityScore() (score int) {
score++
}
if score < 3 && m.EditedAt != nil {
score = 3
}
return score
}

View file

@ -22,8 +22,14 @@ type Place struct {
New bool `gorm:"-"`
}
// UnknownPlace is the default unknown place
var UnknownPlace = NewPlace("-", "Unknown", "Unknown", "Unknown", "zz")
// UnknownPlace is defined here to use it as a default
var UnknownPlace = Place{
ID: "zz",
LocLabel: "Unknown",
LocCity: "Unknown",
LocState: "Unknown",
LocCountry: "zz",
}
// CreateUnknownPlace initializes default place in the database
func CreateUnknownPlace(db *gorm.DB) {
@ -35,41 +41,18 @@ func (m *Place) AfterCreate(scope *gorm.Scope) error {
return scope.SetColumn("New", true)
}
// FindPlace returns place from a token
func FindPlace(token string, db *gorm.DB) *Place {
place := &Place{}
if err := db.First(place, "id = ?", token).Error; err != nil {
log.Debugf("place: %s for token %s", err.Error(), token)
}
return place
}
// FindPlaceByLabel returns a place from an id or a label
func FindPlaceByLabel(id string, label string, db *gorm.DB) *Place {
place := &Place{}
if err := db.First(place, "id = ? OR loc_label = ?", id, label).Error; err != nil {
log.Debugf("place: %s for id %s or label \"%s\"", err.Error(), id, label)
return nil
}
return place
}
// NewPlace registers a new place in database
func NewPlace(id, label, city, state, countryCode string) *Place {
result := &Place{
ID: id,
LocLabel: label,
LocCity: city,
LocState: state,
LocCountry: countryCode,
}
return result
}
// Find returns db record of place
func (m *Place) Find(db *gorm.DB) error {
if err := db.First(m, "id = ?", m.ID).Error; err != nil {
@ -91,9 +74,9 @@ func (m *Place) FirstOrCreate(db *gorm.DB) *Place {
return m
}
// NoID checks is the place has no id
func (m Place) NoID() bool {
return m.ID == ""
// Unknown returns true if this is an unknown place
func (m Place) Unknown() bool {
return m.ID == UnknownPlace.ID
}
// Label returns place label

View file

@ -8,8 +8,8 @@ type GeoSearch struct {
Before time.Time `form:"before" time_format:"2006-01-02"`
After time.Time `form:"after" time_format:"2006-01-02"`
Favorite bool `form:"favorite"`
Lat float64 `form:"lat"`
Lng float64 `form:"lng"`
Lat float32 `form:"lat"`
Lng float32 `form:"lng"`
S2 string `form:"s2"`
Olc string `form:"olc"`
Dist uint `form:"dist"`

View file

@ -24,6 +24,6 @@ func TestGeoSearch(t *testing.T) {
assert.Equal(t, "foobar baz", form.Query)
assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before)
assert.Equal(t, uint(0x61a8), form.Dist)
assert.Equal(t, 33.45343166666667, form.Lat)
assert.Equal(t, float32(33.45343), form.Lat)
})
}

View file

@ -27,15 +27,14 @@ type Photo struct {
DescriptionSrc string `json:"DescriptionSrc"`
PhotoFavorite bool `json:"PhotoFavorite"`
PhotoPrivate bool `json:"PhotoPrivate"`
PhotoNSFW bool `json:"PhotoNSFW"`
PhotoStory bool `json:"PhotoStory"`
PhotoReview bool `json:"PhotoReview"`
PhotoLat float64 `json:"PhotoLat"`
PhotoLng float64 `json:"PhotoLng"`
PhotoLat float32 `json:"PhotoLat"`
PhotoLng float32 `json:"PhotoLng"`
PhotoAltitude int `json:"PhotoAltitude"`
PhotoIso int `json:"PhotoIso"`
PhotoFocalLength int `json:"PhotoFocalLength"`
PhotoFNumber float64 `json:"PhotoFNumber"`
PhotoFNumber float32 `json:"PhotoFNumber"`
PhotoExposure string `json:"PhotoExposure"`
CameraID uint `json:"CameraID"`
CameraSrc string `json:"CameraSrc"`

View file

@ -13,11 +13,11 @@ type PhotoSearch struct {
Duplicate bool `form:"duplicate"`
Archived bool `form:"archived"`
Error bool `form:"error"`
Lat float64 `form:"lat"`
Lng float64 `form:"lng"`
Lat float32 `form:"lat"`
Lng float32 `form:"lng"`
Dist uint `form:"dist"`
Fmin float64 `form:"fmin"`
Fmax float64 `form:"fmax"`
Fmin float32 `form:"fmin"`
Fmax float32 `form:"fmax"`
Chroma uint8 `form:"chroma"`
Diff uint32 `form:"diff"`
Mono bool `form:"mono"`
@ -37,6 +37,7 @@ type PhotoSearch struct {
After time.Time `form:"after" time_format:"2006-01-02"`
Favorites bool `form:"favorites"`
Public bool `form:"public"`
Private bool `form:"private"`
Story bool `form:"story"`
Safe bool `form:"safe"`
Nsfw bool `form:"nsfw"`

View file

@ -34,7 +34,7 @@ func TestParseQueryString(t *testing.T) {
assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before)
assert.Equal(t, false, form.Favorites)
assert.Equal(t, uint(0x61a8), form.Dist)
assert.Equal(t, 33.45343166666667, form.Lat)
assert.Equal(t, float32(33.45343), form.Lat)
})
t.Run("valid query 2", func(t *testing.T) {
form := &PhotoSearch{Query: "chroma:200 title:\"test\" after:2018-01-15 duplicate:false favorites:true lng:33.45343166666667"}
@ -51,7 +51,7 @@ func TestParseQueryString(t *testing.T) {
assert.Equal(t, "test", form.Title)
assert.Equal(t, time.Date(2018, 01, 15, 0, 0, 0, 0, time.UTC), form.After)
assert.Equal(t, false, form.Duplicate)
assert.Equal(t, 33.45343166666667, form.Lng)
assert.Equal(t, float32(33.45343), form.Lng)
})
t.Run("valid query with umlauts", func(t *testing.T) {
form := &PhotoSearch{Query: "title:\"tübingen\""}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/pkg/s2"
@ -23,6 +24,7 @@ type Location struct {
}
var ReverseLookupURL = "https://places.photoprism.org/v1/location/%s"
var client = &http.Client{Timeout: 30 * time.Second} // TODO: Change timeout if needed
func NewLocation(id string, lat float64, lng float64, name string, category string, place Place, cached bool) *Location {
result := &Location{
@ -59,13 +61,24 @@ func FindLocation(id string) (result Location, err error) {
log.Debugf("places: query %s", url)
r, err := http.Get(url)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Errorf("places: %s", err.Error())
return result, err
}
r, err := client.Do(req)
if err != nil {
log.Errorf("places: %s", err.Error())
return result, err
} else if r.StatusCode >= 400 {
err = fmt.Errorf("places: request failed with status code %d", r.StatusCode)
log.Error(err)
return result, err
}
err = json.NewDecoder(r.Body).Decode(&result)
if err != nil {

View file

@ -26,11 +26,11 @@ type Data struct {
Flash bool
FocalLength int
Exposure string
Aperture float64
FNumber float64
Aperture float32
FNumber float32
Iso int
Lat float64
Lng float64
Lat float32
Lng float32
Altitude int
Width int
Height int

View file

@ -167,7 +167,7 @@ func Exif(filename string) (data Data, err error) {
number, _ := strconv.ParseFloat(values[0], 64)
denom, _ := strconv.ParseFloat(values[1], 64)
data.FNumber = math.Round((number/denom)*1000) / 1000
data.FNumber = float32(math.Round((number/denom)*1000) / 1000)
}
}
@ -178,7 +178,7 @@ func Exif(filename string) (data Data, err error) {
number, _ := strconv.ParseFloat(values[0], 64)
denom, _ := strconv.ParseFloat(values[1], 64)
data.Aperture = math.Round((number/denom)*1000) / 1000
data.Aperture = float32(math.Round((number/denom)*1000) / 1000)
}
}
@ -243,16 +243,16 @@ func Exif(filename string) (data Data, err error) {
if ifd, err := index.RootIfd.ChildWithIfdPath(exifcommon.IfdPathStandardGps); err == nil {
if gi, err := ifd.GpsInfo(); err == nil {
data.Lat = gi.Latitude.Decimal()
data.Lng = gi.Longitude.Decimal()
data.Lat = float32(gi.Latitude.Decimal())
data.Lng = float32(gi.Longitude.Decimal())
data.Altitude = gi.Altitude
}
}
if data.Lat != 0 && data.Lng != 0 {
zones, err := tz.GetZone(tz.Point{
Lat: data.Lat,
Lon: data.Lng,
Lat: float64(data.Lat),
Lon: float64(data.Lng),
})
if err != nil {

View file

@ -21,8 +21,8 @@ func TestExif(t *testing.T) {
assert.Equal(t, "This is a legal notice", data.Copyright)
assert.Equal(t, 2736, data.Height)
assert.Equal(t, 3648, data.Width)
assert.Equal(t, 52.459690093888895, data.Lat)
assert.Equal(t, 13.321831703055555, data.Lng)
assert.Equal(t, float32(52.45969), data.Lat)
assert.Equal(t, float32(13.321832), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, "HUAWEI", data.CameraMake)
assert.Equal(t, "ELE-L29", data.CameraModel)
@ -53,8 +53,8 @@ func TestExif(t *testing.T) {
assert.Equal(t, "", data.Copyright)
assert.Equal(t, 540, data.Height)
assert.Equal(t, 720, data.Width)
assert.Equal(t, 51.25485166666667, data.Lat)
assert.Equal(t, 7.389468333333333, data.Lng)
assert.Equal(t, float32(51.254852), data.Lat)
assert.Equal(t, float32(7.389468), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, "Canon", data.CameraMake)
assert.Equal(t, "Canon EOS 50D", data.CameraModel)
@ -82,7 +82,7 @@ func TestExif(t *testing.T) {
assert.Equal(t, "", data.Copyright)
assert.Equal(t, 2880, data.Height)
assert.Equal(t, 3840, data.Width)
assert.Equal(t, 0.0, data.Lng)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, "GoPro", data.CameraMake)
assert.Equal(t, "HD2", data.CameraModel)
@ -107,8 +107,8 @@ func TestExif(t *testing.T) {
assert.Equal(t, "2018-09-10T03:16:13Z", data.TakenAt.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, "2018-09-10T12:16:13Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, 34.79745, data.Lat)
assert.Equal(t, 134.76463333333334, data.Lng)
assert.Equal(t, float32(34.79745), data.Lat)
assert.Equal(t, float32(134.76463), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone 7", data.CameraModel)

View file

@ -144,7 +144,10 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if !ind.conf.DisableTensorFlow() && (fileChanged || o.UpdateKeywords || o.UpdateLabels || o.UpdateTitle) {
// Image classification via TensorFlow
labels = ind.classifyImage(m)
photo.PhotoNSFW = ind.isNSFW(m)
if !photoExists && ind.conf.DetectNSFW() {
photo.PhotoPrivate = ind.NSFW(m)
}
}
if fileChanged || o.UpdateExif {
@ -165,7 +168,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if metaData.Title != "" && (photo.NoTitle() || photo.TitleSrc == entity.SrcExif) {
photo.PhotoTitle = metaData.Title
photo.PhotoTitle = txt.Clip(metaData.Title, 200)
photo.TitleSrc = entity.SrcExif
}
@ -229,7 +232,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
} else {
log.Info("index: no latitude and longitude in metadata")
photo.Place = entity.UnknownPlace
photo.Place = &entity.UnknownPlace
photo.PlaceID = entity.UnknownPlace.ID
}
}
@ -237,7 +240,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
// TODO: Proof-of-concept for indexing XMP sidecar files
if data, err := meta.XMP(m.FileName()); err == nil {
if data.Title != "" && photo.TitleSrc == entity.SrcAuto {
photo.PhotoTitle = data.Title
photo.PhotoTitle = txt.Clip(data.Title, 200)
photo.TitleSrc = entity.SrcXmp
}
@ -260,7 +263,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if photo.Place == nil {
photo.Place = entity.UnknownPlace
photo.Place = &entity.UnknownPlace
photo.PlaceID = entity.UnknownPlace.ID
}
@ -443,12 +446,8 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
return result
}
// isNSFW returns true if media file might be offensive and detection is enabled.
func (ind *Index) isNSFW(jpeg *MediaFile) bool {
if !ind.conf.DetectNSFW() {
return false
}
// NSFW returns true if media file might be offensive and detection is enabled.
func (ind *Index) NSFW(jpeg *MediaFile) bool {
filename, err := jpeg.Thumbnail(ind.thumbnailsPath(), "fit_720")
if err != nil {

View file

@ -177,10 +177,10 @@ func (m *MediaFile) FocalLength() int {
}
// FNumber returns the F number with which the media file was created.
func (m *MediaFile) FNumber() float64 {
func (m *MediaFile) FNumber() float32 {
info, err := m.MetaData()
var result float64
var result float32
if err == nil {
result = info.FNumber
@ -653,7 +653,7 @@ func (m *MediaFile) Height() int {
}
// AspectRatio returns the aspect ratio of a MediaFile.
func (m *MediaFile) AspectRatio() float64 {
func (m *MediaFile) AspectRatio() float32 {
width := float64(m.Width())
height := float64(m.Height())
@ -661,7 +661,7 @@ func (m *MediaFile) AspectRatio() float64 {
return 0
}
aspectRatio := width / height
aspectRatio := float32(width / height)
return aspectRatio
}

View file

@ -151,14 +151,14 @@ func TestMediaFile_FNumber(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg")
assert.Nil(t, err)
assert.Equal(t, 2.2, mediaFile.FNumber())
assert.Equal(t, float32(2.2), mediaFile.FNumber())
})
t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
assert.Nil(t, err)
assert.Equal(t, 10.0, mediaFile.FNumber())
assert.Equal(t, float32(10.0), mediaFile.FNumber())
})
}
@ -977,7 +977,7 @@ func TestMediaFile_AspectRatio(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
assert.Nil(t, err)
ratio := mediaFile.AspectRatio()
assert.Equal(t, 0.75, ratio)
assert.Equal(t, float32(0.75), ratio)
})
t.Run("/fern_green.jpg", func(t *testing.T) {
conf := config.TestConfig()
@ -985,7 +985,7 @@ func TestMediaFile_AspectRatio(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg")
assert.Nil(t, err)
ratio := mediaFile.AspectRatio()
assert.Equal(t, float64(1), ratio)
assert.Equal(t, float32(1), ratio)
})
t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig()
@ -993,7 +993,7 @@ func TestMediaFile_AspectRatio(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
assert.Nil(t, err)
ratio := mediaFile.AspectRatio()
assert.Equal(t, 1.501510574018127, ratio)
assert.Equal(t, float32(1.5015106), ratio)
})
}

View file

@ -35,11 +35,11 @@ func TestMediaFile_Exif_JPEG(t *testing.T) {
assert.Equal(t, "", info.Artist)
assert.Equal(t, 111, info.FocalLength)
assert.Equal(t, "1/640", info.Exposure)
assert.Equal(t, 6.644, info.Aperture)
assert.Equal(t, 10.0, info.FNumber)
assert.Equal(t, float32(6.644), info.Aperture)
assert.Equal(t, float32(10), info.FNumber)
assert.Equal(t, 200, info.Iso)
assert.Equal(t, -33.45347, info.Lat)
assert.Equal(t, 25.764645, info.Lng)
assert.Equal(t, float32(-33.45347), info.Lat)
assert.Equal(t, float32(25.764645), info.Lng)
assert.Equal(t, 190, info.Altitude)
assert.Equal(t, 2048, info.Width)
assert.Equal(t, 1365, info.Height)
@ -70,8 +70,8 @@ func TestMediaFile_Exif_JPEG(t *testing.T) {
assert.Equal(t, "", info.Artist)
assert.Equal(t, 100, info.FocalLength)
assert.Equal(t, "1/250", info.Exposure)
assert.Equal(t, 6.644, info.Aperture)
assert.Equal(t, 10.0, info.FNumber)
assert.Equal(t, float32(6.644), info.Aperture)
assert.Equal(t, float32(10), info.FNumber)
assert.Equal(t, 200, info.Iso)
assert.Equal(t, 0, info.Altitude)
assert.Equal(t, 2048, info.Width)
@ -110,10 +110,10 @@ func TestMediaFile_Exif_DNG(t *testing.T) {
assert.Equal(t, "", info.Artist)
assert.Equal(t, 65, info.FocalLength)
assert.Equal(t, "1/60", info.Exposure)
assert.Equal(t, 4.971, info.Aperture)
assert.Equal(t, float32(4.971), info.Aperture)
assert.Equal(t, 1000, info.Iso)
assert.Equal(t, 0.0, info.Lat)
assert.Equal(t, 0.0, info.Lng)
assert.Equal(t, float32(0), info.Lat)
assert.Equal(t, float32(0), info.Lng)
assert.Equal(t, 0, info.Altitude)
assert.Equal(t, 256, info.Width)
assert.Equal(t, 171, info.Height)
@ -162,10 +162,10 @@ func TestMediaFile_Exif_HEIF(t *testing.T) {
assert.Equal(t, "", jpegInfo.Artist)
assert.Equal(t, 74, jpegInfo.FocalLength)
assert.Equal(t, "1/4000", jpegInfo.Exposure)
assert.Equal(t, 1.696, jpegInfo.Aperture)
assert.Equal(t, float32(1.696), jpegInfo.Aperture)
assert.Equal(t, 20, jpegInfo.Iso)
assert.Equal(t, 34.79745, jpegInfo.Lat)
assert.Equal(t, 134.76463333333334, jpegInfo.Lng)
assert.Equal(t, float32(34.79745), jpegInfo.Lat)
assert.Equal(t, float32(134.76463), jpegInfo.Lng)
assert.Equal(t, 0, jpegInfo.Altitude)
assert.Equal(t, 4032, jpegInfo.Width)
assert.Equal(t, 3024, jpegInfo.Height)

View file

@ -20,7 +20,7 @@ func TestNewCountry(t *testing.T) {
country := entity.NewCountry("", "")
assert.Equal(t, "zz", country.ID)
assert.Equal(t, "Unknown", country.CountryName)
assert.Equal(t, "unknown", country.CountrySlug)
assert.Equal(t, "zz", country.CountrySlug)
})
}
func TestCountry_FirstOrCreate(t *testing.T) {

View file

@ -14,8 +14,8 @@ import (
// GeoResult represents a photo for displaying it on a map.
type GeoResult struct {
ID string `json:"ID"`
PhotoLat float64 `json:"Lat"`
PhotoLng float64 `json:"Lng"`
PhotoLat float32 `json:"Lat"`
PhotoLng float32 `json:"Lng"`
PhotoUUID string `json:"PhotoUUID"`
PhotoTitle string `json:"PhotoTitle"`
PhotoFavorite bool `json:"PhotoFavorite"`
@ -25,6 +25,14 @@ type GeoResult struct {
TakenAt time.Time `json:"TakenAt"`
}
func (g GeoResult) Lat() float64 {
return float64(g.PhotoLat)
}
func (g GeoResult) Lng() float64 {
return float64(g.PhotoLng)
}
// Geo searches for photos based on a Form and returns a PhotoResult slice.
func (q *Query) Geo(f form.GeoSearch) (results []GeoResult, err error) {
if err := f.ParseQueryString(); err != nil {
@ -69,14 +77,14 @@ func (q *Query) Geo(f form.GeoSearch) (results []GeoResult, err error) {
} else {
// Inaccurate distance search, but probably 'good enough' for now
if f.Lat > 0 {
latMin := f.Lat - SearchRadius*float64(f.Dist)
latMax := f.Lat + SearchRadius*float64(f.Dist)
latMin := f.Lat - SearchRadius*float32(f.Dist)
latMax := f.Lat + SearchRadius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax)
}
if f.Lng > 0 {
lngMin := f.Lng - SearchRadius*float64(f.Dist)
lngMax := f.Lng + SearchRadius*float64(f.Dist)
lngMin := f.Lng - SearchRadius*float32(f.Dist)
lngMax := f.Lng + SearchRadius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax)
}
}

View file

@ -33,12 +33,12 @@ type PhotoResult struct {
PhotoCountry string
PhotoFavorite bool
PhotoPrivate bool
PhotoLat float64
PhotoLng float64
PhotoLat float32
PhotoLng float32
PhotoAltitude int
PhotoIso int
PhotoFocalLength int
PhotoFNumber float64
PhotoFNumber float32
PhotoExposure string
PhotoQuality int
PhotoResolution int
@ -74,7 +74,7 @@ type PhotoResult struct {
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
FileAspectRatio float32
FileColors string // todo: remove from result?
FileChroma uint8 // todo: remove from result?
FileLuminance string // todo: remove from result?
@ -244,6 +244,18 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err
s = s.Where("photos.deleted_at IS NOT NULL")
} else {
s = s.Where("photos.deleted_at IS NULL")
if f.Private {
s = s.Where("photos.photo_private = 1")
} else if f.Public {
s = s.Where("photos.photo_private = 0")
}
if f.Review {
s = s.Where("photos.photo_quality < 3")
} else if f.Quality != 0 && f.Private == false {
s = s.Where("photos.photo_quality >= ?", f.Quality)
}
}
if f.Error {
@ -278,18 +290,6 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err
s = s.Where("photos.photo_favorite = 1")
}
if f.Public {
s = s.Where("photos.photo_private = 0")
}
if f.Safe {
s = s.Where("photos.photo_nsfw = 0")
}
if f.Nsfw {
s = s.Where("photos.photo_nsfw = 1")
}
if f.Story {
s = s.Where("photos.photo_story = 1")
}
@ -322,12 +322,6 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err
s = s.Where("files.file_chroma > 0 AND files.file_chroma <= ?", f.Chroma)
}
if f.Review {
s = s.Where("photos.photo_quality < 3")
} else if f.Quality != 0 {
s = s.Where("photos.photo_quality >= ?", f.Quality)
}
if f.Diff != 0 {
s = s.Where("files.file_diff = ?", f.Diff)
}
@ -348,14 +342,14 @@ func (q *Query) Photos(f form.PhotoSearch) (results PhotoResults, count int, err
// Inaccurate distance search, but probably 'good enough' for now
if f.Lat > 0 {
latMin := f.Lat - SearchRadius*float64(f.Dist)
latMax := f.Lat + SearchRadius*float64(f.Dist)
latMin := f.Lat - SearchRadius*float32(f.Dist)
latMax := f.Lat + SearchRadius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax)
}
if f.Lng > 0 {
lngMin := f.Lng - SearchRadius*float64(f.Dist)
lngMax := f.Lng + SearchRadius*float64(f.Dist)
lngMin := f.Lng - SearchRadius*float32(f.Dist)
lngMax := f.Lng + SearchRadius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax)
}

View file

@ -12,9 +12,10 @@ package remote
import (
"net/http"
"time"
)
var client = &http.Client{}
var client = &http.Client{Timeout: 30 * time.Second} // TODO: Change timeout if needed
const (
ServiceWebDAV = "webdav"

View file

@ -12,6 +12,7 @@ import (
"io/ioutil"
"os"
"path"
"time"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
@ -28,6 +29,8 @@ type Client struct {
func New(url, user, pass string) Client {
clt := gowebdav.NewClient(url, user, pass)
clt.SetTimeout(10 * time.Minute) // TODO: Change timeout if needed
result := Client{client: clt}
return result