Merge branch 'develop'

This commit is contained in:
Michael Mayer 2019-11-17 03:20:46 +01:00
commit 824fb9d063
90 changed files with 10292 additions and 2461 deletions

18
.fossa.yml Executable file
View file

@ -0,0 +1,18 @@
# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli)
# Visit https://fossa.com to learn more
version: 2
cli:
server: https://app.fossa.com
fetcher: custom
project: git@github.com:photoprism/photoprism.git
analyze:
modules:
- name: github.com/photoprism/photoprism/cmd/photoprism
type: go
target: github.com/photoprism/photoprism/cmd/photoprism
path: cmd/photoprism
- name: frontend
type: npm
target: frontend
path: frontend

View file

@ -1,4 +1,4 @@
FROM photoprism/development:20190919
FROM photoprism/development:20191105
# Set up project directory
WORKDIR "/go/src/github.com/photoprism/photoprism"

7152
NOTICE.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
debug: false
read-only: false
public: false
admin-password: photoprism
config-path: ~/.config/photoprism
cache-path: ~/.cache/photoprism
assets-path: ~/.local/share/photoprism
@ -12,7 +15,6 @@ sql-password: photoprism
http-host:
http-mode: release
http-port: 2342
http-password:
database-driver: internal
database-dsn: root:photoprism@tcp(localhost:4000)/photoprism?parseTime=true
pid-filename: ~/.local/share/photoprism/photoprism.pid

View file

@ -0,0 +1,2 @@
theme: dark
language: en

View file

@ -33,14 +33,16 @@
<script>
window.appConfig = {
name: "{{ .name }}",
version: "{{ .version }}",
copyright: "{{ .copyright }}",
debug: {{ .debug }},
readonly: {{ .readonly }},
cameras: {{ .cameras }},
countries: {{ .countries }},
thumbnails: {{ .thumbnails }}
"name": "{{ .name }}",
"version": "{{ .version }}",
"copyright": "{{ .copyright }}",
"debug": {{ .debug }},
"readonly": {{ .readonly }},
"public": {{ .public }},
"cameras": {{ .cameras }},
"countries": {{ .countries }},
"thumbnails": {{ .thumbnails }},
"settings": {{ .settings }},
};
</script>
</head>
@ -63,6 +65,8 @@
</div>
</div>
<div id="p-busy-overlay"></div>
<script src="/static/build/app.js?{{ .jsHash }}"></script>
</body>
</html>

View file

@ -5,7 +5,7 @@ services:
build: .
image: photoprism/photoprism:develop
depends_on:
- photoprism-mysql
- photoprism-db
command: tail -f /dev/null
volumes:
- "~/.cache/npm:/root/.cache/npm"
@ -34,8 +34,8 @@ services:
CI_BUILD_ID:
CI_JOB_ID:
photoprism-mysql:
image: mysql:8.0.16
photoprism-db:
image: mariadb:10.4.8
command: mysqld --port=4001 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=1024
expose:
- "4001"

View file

@ -5,7 +5,7 @@ services:
build: .
image: photoprism/photoprism:develop
depends_on:
- photoprism-mysql
- photoprism-db
ports:
- "2342:2342" # Web Server (PhotoPrism)
- "4000:4000" # Database (MySQL compatible)
@ -31,8 +31,8 @@ services:
PHOTOPRISM_SQL_PASSWORD: "photoprism"
TF_CPP_MIN_LOG_LEVEL: 0
photoprism-mysql:
image: mysql:8.0.16
photoprism-db:
image: mariadb:10.4.8
command: mysqld --port=4001 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=1024
expose:
- "4001"

View file

@ -9,4 +9,4 @@ RUN wget -qO- https://dl.photoprism.org/fixtures/demo.tar.gz | tar xvz -C Pictur
RUN photoprism import
# Start PhotoPrism server
CMD photoprism start
CMD photoprism --public start

View file

@ -82,12 +82,12 @@ RUN npm install --unsafe-perm=true --allow-root -g npm testcafe chromedriver
RUN npm config set cache ~/.cache/npm
# Install Go
ENV GOLANG_VERSION 1.13
ENV GOLANG_VERSION 1.13.4
RUN set -eux; \
\
url="https://golang.org/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz"; \
wget -O go.tgz "$url"; \
echo "68a2297eb099d1a76097905a2ce334e3155004ec08cdea85f24527be3c48e856 *go.tgz" | sha256sum -c -; \
echo "692d17071736f74be04a72a06dab9cac1cd759377bd85316e52b2227604c004c *go.tgz" | sha256sum -c -; \
tar -C /usr/local -xzf go.tgz; \
rm go.tgz; \
export PATH="/usr/local/go/bin:$PATH"; \

View file

@ -1,4 +1,4 @@
FROM photoprism/development:20190919 as build
FROM photoprism/development:20191105 as build
# Set up project directory
WORKDIR "/go/src/github.com/photoprism/photoprism"

File diff suppressed because it is too large Load diff

View file

@ -14,34 +14,34 @@
"test-firefox": "testcafe firefox:headless --selector-timeout 5000 -S -s tests/screenshots tests/acceptance"
},
"dependencies": {
"@babel/cli": "^7.6.0",
"@babel/core": "^7.6.0",
"@babel/plugin-transform-runtime": "^7.6.0",
"@babel/polyfill": "^7.6.0",
"@babel/preset-env": "^7.6.0",
"@babel/register": "^7.6.0",
"@fortawesome/fontawesome-free": "^5.10.2",
"@types/leaflet": "^1.5.1",
"@babel/cli": "^7.7.0",
"@babel/core": "^7.7.2",
"@babel/plugin-transform-runtime": "^7.6.2",
"@babel/polyfill": "^7.7.0",
"@babel/preset-env": "^7.7.1",
"@babel/register": "^7.7.0",
"@fortawesome/fontawesome-free": "^5.11.2",
"@types/leaflet": "^1.5.5",
"acorn": "^6.3.0",
"ajv": "^6.10.2",
"autoprefixer": "^9.6.1",
"autoprefixer": "^9.7.1",
"axios": "^0.19.0",
"axios-mock-adapter": "^1.17.0",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"babel-plugin-istanbul": "^5.2.0",
"browserslist": "^4.7.0",
"browserslist": "^4.7.2",
"chai": "^4.2.0",
"chalk": "^2.4.2",
"chart.js": "^2.5.0",
"chrome-finder": "^1.0.5",
"chart.js": "^2.9.2",
"chrome-finder": "^1.0.6",
"clean-webpack-plugin": "^3.0.0",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^5.0.4",
"copy-webpack-plugin": "^5.0.5",
"cross-env": "^5.2.1",
"css-loader": "^2.1.1",
"cssnano": "^4.1.10",
"eslint": "^6.4.0",
"eslint": "^6.6.0",
"eslint-config-standard": "^13.0.1",
"eslint-formatter-pretty": "^2.1.1",
"eslint-friendly-formatter": "^4.0.1",
@ -57,7 +57,7 @@
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.19.1",
"inject-loader": "^4.0.1",
"karma": "^4.3.0",
"karma": "^4.4.1",
"karma-chrome-launcher": "^3.1.0",
"karma-coverage-istanbul-reporter": "^2.1.0",
"karma-htmlfile-reporter": "^0.3.8",
@ -65,16 +65,16 @@
"karma-verbose-reporter": "^0.0.6",
"karma-webpack": "^4.0.2",
"leaflet": "^1.5.1",
"luxon": "^1.17.3",
"luxon": "^1.21.1",
"material-design-icons-iconfont": "^5.0.1",
"mini-css-extract-plugin": "^0.7.0",
"mocha": "^6.2.0",
"moment-timezone": "^0.5.26",
"mocha": "^6.2.2",
"moment-timezone": "^0.5.27",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"ora": "^3.4.0",
"photoswipe": "^4.1.3",
"pluralize": "^8.0.0",
"postcss": "^7.0.18",
"postcss": "^7.0.21",
"postcss-browser-reporter": "^0.6.0",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
@ -83,17 +83,19 @@
"postcss-url": "^8.0.0",
"pubsub-js": "^1.7.0",
"puppeteer-core": "^1.20.0",
"resolve-url-loader": "^3.1.0",
"resolve-url-loader": "^3.1.1",
"sass-loader": "^7.3.1",
"sinon": "^7.4.2",
"sinon": "^7.5.0",
"sockette": "^2.0.6",
"style-loader": "^0.23.1",
"sugarss": "^2.0.0",
"svg-url-loader": "^2.3.3",
"tar": "^4.4.10",
"tar": "^4.4.13",
"truncate": "^2.1.0",
"url-loader": "^1.1.2",
"vue": "^2.6.10",
"vue-fullscreen": "^2.1.5",
"vue-gettext": "^2.1.6",
"vue-infinite-scroll": "^2.0.2",
"vue-loader": "^14.2.4",
"vue-luxon": "^0.7.0",
@ -103,10 +105,10 @@
"vue2-filters": "^0.6.1",
"vue2-leaflet": "^2.2.1",
"vuelidate": "^0.7.4",
"vuetify": "^1.5.18",
"webpack": "^4.40.2",
"webpack-bundle-analyzer": "^3.5.0",
"webpack-cli": "^3.3.8",
"vuetify": "^1.5.21",
"webpack": "^4.41.2",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",
"webpack-hot-middleware": "^2.25.0",
"webpack-md5-hash": "0.0.6",
"webpack-merge": "^4.2.2"

View file

@ -1,36 +1,40 @@
import Vue from "vue";
import Vuetify from "vuetify";
import Router from "vue-router";
import PhotoPrism from "photoprism.vue";
import Routes from "routes";
import Api from "common/api";
import Notify from "common/notify";
import Config from "common/config";
import Clipboard from "common/clipboard";
import Components from "component/components";
import Dialogs from "dialog/dialogs";
import Maps from "maps/components";
import Alert from "common/alert";
import Viewer from "common/viewer";
import Session from "common/session";
import Event from "pubsub-js";
import VueLuxon from "vue-luxon";
import VueInfiniteScroll from "vue-infinite-scroll";
import VueFullscreen from "vue-fullscreen";
import VueFilters from "vue2-filters";
import GetTextPlugin from "vue-gettext";
import Maps from "maps/components";
import PhotoPrism from "photoprism.vue";
import Router from "vue-router";
import Routes from "routes";
import Session from "session";
import { Settings } from "luxon";
import Socket from "common/websocket";
import Translations from "./i18n/translations.json";
import Viewer from "common/viewer";
import Vue from "vue";
import Vuetify from "vuetify";
import VueLuxon from "vue-luxon";
import VueFilters from "vue2-filters";
import VueFullscreen from "vue-fullscreen";
import VueInfiniteScroll from "vue-infinite-scroll";
// Initialize helpers
const session = new Session(window.localStorage);
const config = new Config(window.localStorage, window.appConfig);
const viewer = new Viewer();
const clipboard = new Clipboard(window.localStorage, "photo_clipboard");
const isPublic = config.getValue("public");
// Assign helpers to VueJS prototype
Vue.prototype.$event = Event;
Vue.prototype.$alert = Alert;
Vue.prototype.$notify = Notify;
Vue.prototype.$viewer = viewer;
Vue.prototype.$session = session;
Vue.prototype.$session = Session;
Vue.prototype.$api = Api;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = clipboard;
@ -49,9 +53,11 @@ Vue.use(Vuetify, {
},
});
Settings.defaultLocale = "en";
Vue.config.language = "en";
Settings.defaultLocale = Vue.config.language;
// Register other VueJS plugins
Vue.use(GetTextPlugin, {translations: Translations, silent: false, defaultLanguage: Vue.config.language});
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
@ -68,6 +74,30 @@ const router = new Router({
saveScrollPosition: true,
});
router.beforeEach((to, from, next) => {
if(to.matched.some(record => record.meta.admin)) {
if (isPublic || Session.isAdmin()) {
next();
} else {
next({
name: "login",
params: { nextUrl: to.fullPath },
});
}
} else if(to.matched.some(record => record.meta.auth)) {
if (isPublic || Session.isUser()) {
next();
} else {
next({
name: "login",
params: { nextUrl: to.fullPath },
});
}
} else {
next();
}
});
// Run app
/* eslint-disable no-unused-vars */
const app = new Vue({

View file

@ -1,18 +0,0 @@
import Event from "pubsub-js";
const Alert = {
info: function (message) {
Event.publish("alert.info", message);
},
warning: function (message) {
Event.publish("alert.warning", message);
},
error: function (message) {
Event.publish("alert.error", message);
},
success: function (message) {
Event.publish("alert.success", message);
},
};
export default Alert;

View file

@ -1,8 +1,8 @@
import axios from "axios";
import Event from "pubsub-js";
import "@babel/polyfill/noConflict";
import Axios from "axios";
import Notify from "common/notify";
const Api = axios.create({
const Api = Axios.create({
baseURL: "/api/v1",
headers: {common: {
"X-Session-Token": window.localStorage.getItem("session_token"),
@ -11,7 +11,7 @@ const Api = axios.create({
Api.interceptors.request.use(function (config) {
// Do something before request is sent
Event.publish("ajax.start", config);
Notify.ajaxStart();
return config;
}, function (error) {
// Do something with request error
@ -19,10 +19,15 @@ Api.interceptors.request.use(function (config) {
});
Api.interceptors.response.use(function (response) {
Event.publish("ajax.end", response);
Notify.ajaxEnd();
return response;
}, function (error) {
Notify.ajaxEnd();
if (Axios.isCancel(error)) {
return Promise.reject(error);
}
if(console && console.log) {
console.log(error);
}
@ -36,12 +41,7 @@ Api.interceptors.response.use(function (response) {
errorMessage = data.message ? data.message : data.error;
}
Event.publish("ajax.end");
Event.publish("alert.error", errorMessage);
if(code === 401) {
window.location = "/";
}
Notify.error(errorMessage);
return Promise.reject(error);
});

View file

@ -1,4 +1,4 @@
import Api from "common/api";
import Event from "pubsub-js";
class Config {
/**
@ -11,15 +11,14 @@ class Config {
this.values = values;
// this.setValues(JSON.parse(this.storage.getItem(this.storage_key)));
// this.setValues(values);
this.subscriptionId = Event.subscribe('config.updated', (ev, data) => this.setValues(data));
}
setValues(values) {
if(!values) return;
if (!values) return;
for(let key in values) {
if(values.hasOwnProperty(key)) {
for (let key in values) {
if (values.hasOwnProperty(key)) {
this.setValue(key, values[key]);
}
}
@ -52,14 +51,6 @@ class Config {
return this;
}
pullFromServer() {
return Api.get("config").then(
(result) => {
this.setValues(result.data);
}
);
}
}
export default Config;

View file

@ -0,0 +1,38 @@
import Event from "pubsub-js";
const Notify = {
info: function (message) {
Event.publish("notify.info", {msg: message});
},
warning: function (message) {
Event.publish("notify.warning", {msg: message});
},
error: function (message) {
Event.publish("notify.error", {msg: message});
},
success: function (message) {
Event.publish("notify.success", {msg: message});
},
ajaxStart: function() {
Event.publish("ajax.start");
},
ajaxEnd: function() {
Event.publish("ajax.end");
},
blockUI: function() {
const el = document.getElementById('p-busy-overlay');
if(el) {
el.style.display = 'block';
}
},
unblockUI: function() {
const el = document.getElementById('p-busy-overlay');
if(el) {
el.style.display = 'none';
}
}
};
export default Notify;

View file

@ -1,23 +1,55 @@
import Api from "common/api";
import User from "model/user";
import Api from "./api";
import User from "../model/user";
class Session {
export default class Session {
/**
* @param {Storage} storage
*/
constructor(storage) {
this.storage = storage;
this.session_token = this.storage.getItem("session_token");
this.auth = false;
const userJson = this.storage.getItem("user");
if (storage.getItem("session_storage") === "true") {
this.storage = window.sessionStorage;
} else {
this.storage = storage;
}
this.user = userJson !== "undefined" ? new User(JSON.parse(userJson)) : null;
if (this.applyToken(this.storage.getItem("session_token"))) {
const userJson = this.storage.getItem("user");
this.user = userJson !== "undefined" ? new User(JSON.parse(userJson)) : null;
}
if (this.isUser()) {
this.auth = true;
}
}
useSessionStorage() {
this.deleteToken();
this.storage.setItem("session_storage", "true");
this.storage = window.sessionStorage;
}
useLocalStorage() {
this.storage.setItem("session_storage", "false");
this.storage = window.localStorage;
}
applyToken(token) {
if (!token) {
this.deleteToken();
return false;
}
this.session_token = token;
Api.defaults.headers.common["X-Session-Token"] = token;
return true;
}
setToken(token) {
this.session_token = token;
this.storage.setItem("session_token", token);
Api.defaults.headers.common["X-Session-Token"] = token;
return this.applyToken(token);
}
getToken() {
@ -27,13 +59,14 @@ class Session {
deleteToken() {
this.session_token = null;
this.storage.removeItem("session_token");
Api.defaults.headers.common["X-Session-Token"] = "";
delete Api.defaults.headers.common["X-Session-Token"];
this.deleteUser();
}
setUser(user) {
this.user = user;
this.storage.setItem("user", JSON.stringify(user.getValues()));
this.auth = true;
}
getUser() {
@ -42,15 +75,7 @@ class Session {
getEmail() {
if (this.isUser()) {
return this.user.userEmail;
}
return "";
}
getFullName() {
if (this.isUser()) {
return this.user.userFirstName + " " + this.user.userLastName;
return this.user.Email;
}
return "";
@ -58,25 +83,34 @@ class Session {
getFirstName() {
if (this.isUser()) {
return this.user.userFirstName;
return this.user.FirstName;
}
return "";
}
getFullName() {
if (this.isUser()) {
return this.user.FirstName + " " + this.user.LastName;
}
return "";
}
isUser() {
return this.user.hasId();
return this.user && this.user.hasId();
}
isAdmin() {
return this.user.hasId() && this.user.userRole === "admin";
return this.user && this.user.hasId() && this.user.Role === "admin";
}
isAnonymous() {
return !this.user.hasId();
return !this.user || !this.user.hasId();
}
deleteUser() {
this.auth = false;
this.user = null;
this.storage.removeItem("user");
}
@ -84,7 +118,7 @@ class Session {
login(email, password) {
this.deleteToken();
return Api.post("session", { email: email, password: password }).then(
return Api.post("session", {email: email, password: password}).then(
(result) => {
this.setToken(result.data.token);
this.setUser(new User(result.data.user));
@ -104,5 +138,3 @@ class Session {
);
}
}
export default Session;

View file

@ -0,0 +1,22 @@
import Sockette from "sockette";
import Event from "pubsub-js";
const host = window.location.host;
const Socket = new Sockette("ws://" + host + "/api/v1/ws", {
timeout: 5e3,
onopen: e => {
console.log('Connected!', e);
Socket.send("hello world");
},
onmessage: e => {
const m = JSON.parse(e.data);
console.log('Received:', m);
Event.publish(m.event, m.data);
},
onreconnect: e => console.log('Reconnecting...', e),
onmaximum: e => console.log('Stop Attempting!', e),
onclose: e => console.log('Closed!', e),
onerror: e => console.log('Error:', e)
});
export default Socket;

View file

@ -1,4 +1,4 @@
import PAlert from "./p-alert.vue";
import PNotify from "./p-notify.vue";
import PNavigation from "./p-navigation.vue";
import PLoadingBar from "./p-loading-bar.vue";
import PPhotoDetails from "./p-photo-details.vue";
@ -13,7 +13,7 @@ import PScrollTop from "./p-scroll-top.vue";
const components = {};
components.install = (Vue) => {
Vue.component("p-alert", PAlert);
Vue.component("p-notify", PNotify);
Vue.component("p-navigation", PNavigation);
Vue.component("p-loading-bar", PLoadingBar);
Vue.component("p-photo-details", PPhotoDetails);

View file

@ -57,10 +57,6 @@
},
mounted () {
const overlay = document.createElement("div");
overlay.id = 'p-busy-overlay';
document.body.appendChild(overlay);
let stackSize = 0;
this.$event.subscribe('ajax.start', function () {
@ -68,7 +64,6 @@
if(stackSize === 1) {
this.start();
document.getElementById('p-busy-overlay').style.display = 'block';
}
}.bind(this));
@ -77,7 +72,6 @@
if (stackSize === 0) {
this.done();
document.getElementById('p-busy-overlay').style.display = 'none';
}
}.bind(this));
},

View file

@ -1,6 +1,7 @@
<template>
<div id="p-navigation">
<v-toolbar dark scroll-off-screen color="grey darken-3" class="hidden-lg-and-up p-navigation-small" @click.stop="showNavigation()">
<v-toolbar dark scroll-off-screen color="grey darken-3" class="hidden-lg-and-up p-navigation-small"
@click.stop="showNavigation()">
<v-toolbar-side-icon class="p-navigation-show"></v-toolbar-side-icon>
<v-toolbar-title class="p-navigation-title">{{ $router.currentRoute.meta.area }}</v-toolbar-title>
@ -58,13 +59,13 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'mono:true' }}" :exact="true" @click="">
<v-list-tile :to="{name: 'photos', query: { q: 'mono:true' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Monochrome</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'Photos', query: { q: 'chroma:50' }}" :exact="true" @click="">
<v-list-tile :to="{name: 'photos', query: { q: 'chroma:50' }}" :exact="true" @click="">
<v-list-tile-content>
<v-list-tile-title>Vibrant</v-list-tile-title>
</v-list-tile-content>
@ -88,7 +89,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click.stop="$alert.warning('Work in progress')">
<v-list-tile @click.stop="$notify.warning('Work in progress')">
<v-list-tile-content>
<v-list-tile-title>Work in progress...</v-list-tile-title>
</v-list-tile-content>
@ -125,7 +126,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/events" @click="" class="p-navigation-events">
<!-- v-list-tile to="/events" @click="" class="p-navigation-events">
<v-list-tile-action>
<v-icon>date_range</v-icon>
</v-list-tile-action>
@ -133,9 +134,9 @@
<v-list-tile-content>
<v-list-tile-title>Events</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-tile -->
<v-list-tile to="/people" @click="" class="p-navigation-people">
<!-- v-list-tile to="/people" @click="" class="p-navigation-people">
<v-list-tile-action>
<v-icon>people</v-icon>
</v-list-tile-action>
@ -143,9 +144,9 @@
<v-list-tile-content>
<v-list-tile-title>People</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-tile -->
<v-list-tile to="/library" @click="" class="p-navigation-library">
<v-list-tile to="/library" @click="" class="p-navigation-library" v-if="session.auth || isPublic">
<v-list-tile-action>
<v-icon>camera_roll</v-icon>
</v-list-tile-action>
@ -155,7 +156,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/settings" @click="" class="p-navigation-settings">
<v-list-tile to="/settings" @click="" class="p-navigation-settings" v-if="session.auth || isPublic">
<v-list-tile-action>
<v-icon>settings</v-icon>
</v-list-tile-action>
@ -164,6 +165,26 @@
<v-list-tile-title>Settings</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile @click="logout" class="p-navigation-logout" v-if="!isPublic && session.auth">
<v-list-tile-action>
<v-icon>power_settings_new</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Logout</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/login" @click="" class="p-navigation-login" v-if="!isPublic && !session.auth">
<v-list-tile-action>
<v-icon>lock</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Login</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
</v-navigation-drawer>
</div>
@ -178,13 +199,18 @@
return {
drawer: null,
mini: mini,
session: this.$session,
isPublic: this.$config.getValue("public"),
};
},
methods: {
showNavigation: function () {
this.drawer = true;
this.mini = false;
}
},
logout() {
this.$session.logout();
},
}
};
</script>

View file

@ -1,6 +1,6 @@
<template>
<v-snackbar
id="p-alert"
id="p-notify"
v-model="visible"
:color="color"
:timeout="0"
@ -23,7 +23,7 @@
import Event from 'pubsub-js';
export default {
name: 'p-alert',
name: 'p-notify',
data() {
return {
text: '',
@ -37,30 +37,36 @@
};
},
created() {
this.subscriptionId = Event.subscribe('alert', this.handleAlertEvent);
this.subscriptionId = Event.subscribe('notify', this.eventHandler);
},
destroyed() {
Event.unsubscribe(this.subscriptionId);
},
methods: {
handleAlertEvent: function (ev, message) {
eventHandler: function (ev, data) {
const type = ev.split('.')[1];
// get message from data object
let m = data.msg;
// first letter uppercase
m = m.replace(/^./, m[0].toUpperCase());
switch (type) {
case 'warning':
this.addWarningMessage(message);
this.addWarningMessage(m);
break;
case 'error':
this.addErrorMessage(message);
this.addErrorMessage(m);
break;
case 'success':
this.addSuccessMessage(message);
this.addSuccessMessage(m);
break;
case 'info':
this.addInfoMessage(message);
this.addInfoMessage(m);
break;
default:
alert(message);
alert(m);
}
},
@ -86,9 +92,15 @@
this.lastMessageId++;
this.lastMessage = message;
const alert = {'id': this.lastMessageId, 'color': color, 'textColor': textColor, 'delay': delay, 'msg': message};
const m = {
'id': this.lastMessageId,
'color': color,
'textColor': textColor,
'delay': delay,
'msg': message
};
this.messages.push(alert);
this.messages.push(m);
if(!this.visible) {
this.show();

View file

@ -102,8 +102,8 @@
</div>
</template>
<script>
import Event from "pubsub-js";
import axios from "axios";
import Api from "common/api";
import Notify from "common/notify";
export default {
name: 'p-photo-clipboard',
@ -125,59 +125,44 @@
this.expanded = false;
},
batchPrivate() {
Event.publish("ajax.start");
const ctx = this;
axios.post("/api/v1/batch/photos/private", {"ids": this.selection}).then(function () {
Event.publish("ajax.end");
Event.publish("alert.success", "Toggled private flag");
Api.post("batch/photos/private", {"ids": this.selection}).then(function () {
Notify.success("Toggled private flag");
ctx.clearClipboard();
ctx.refresh();
}).catch(() => {
Event.publish("ajax.end");
});
},
batchStory() {
Event.publish("ajax.start");
const ctx = this;
axios.post("/api/v1/batch/photos/story", {"ids": this.selection}).then(function () {
Event.publish("ajax.end");
Event.publish("alert.success", "Toggled story flag");
Api.post("batch/photos/story", {"ids": this.selection}).then(function () {
Notify.success("Toggled story flag");
ctx.clearClipboard();
ctx.refresh();
}).catch(() => {
Event.publish("ajax.end");
});
},
batchDelete() {
this.dialog.delete = false;
Event.publish("ajax.start");
const ctx = this;
axios.post("/api/v1/batch/photos/delete", {"ids": this.selection}).then(function () {
Event.publish("ajax.end");
Event.publish("alert.success", "Photos deleted");
Api.post("batch/photos/delete", {"ids": this.selection}).then(function () {
Notify.success("Photos deleted");
ctx.clearClipboard();
ctx.refresh();
}).catch(() => {
Event.publish("ajax.end");
});
},
batchTag() {
this.$alert.warning("Not implemented yet");
Notify.warning("Not implemented yet");
this.expanded = false;
},
batchAlbum() {
this.$alert.warning("Not implemented yet");
Notify.warning("Not implemented yet");
this.expanded = false;
},
batchDownload() {
this.$alert.warning("Not implemented yet");
Notify.warning("Not implemented yet");
this.expanded = false;
},
openDocs() {

View file

@ -18,7 +18,8 @@
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'">
: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="grey lighten-2"

View file

@ -18,7 +18,8 @@
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'">
:class="$clipboard.has(photo) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
:title="photo.PhotoTitle">
<v-img :src="photo.getThumbnailUrl('tile_500')"
aspect-ratio="1"
class="grey lighten-2"

View file

@ -0,0 +1,5 @@
{
"en": {
"theme": "Theme"
}
}

View file

@ -0,0 +1,56 @@
import Api from "common/api";
class Settings {
constructor(values) {
this.__originalValues = {};
if (!values) {
values = {
theme: "dark",
language: "en",
};
}
console.log("config values", values);
this.setValues(values);
}
setValues(values) {
if(!values) return;
for(let key in values) {
if(values.hasOwnProperty(key) && key !== "__originalValues") {
this[key] = values[key];
this.__originalValues[key] = values[key];
}
}
return this;
}
getValues() {
const result = {};
for(let key in this.__originalValues) {
if(this.__originalValues.hasOwnProperty(key) && key !== "__originalValues") {
result[key] = this[key];
}
}
return result;
}
load() {
return Api.get("settings").then((response) => {
return Promise.resolve(this.setValues(response.data));
});
}
save() {
return Api.post("settings", this.getValues()).then((response) => Promise.resolve(this.setValues(response.data)));
}
}
export default Settings;

View file

@ -4,7 +4,7 @@ import Api from "common/api";
class User extends Abstract {
getEntityName() {
return this.userFirstName + " " + this.userLastName;
return this.FirstName + " " + this.LastName;
}
getId() {

View file

@ -125,7 +125,7 @@
},
openAlbum(index) {
const album = this.results[index];
this.$router.push({name: 'Photos', query: {q: "album:" + album.AlbumSlug}});
this.$router.push({name: "photos", query: {q: "album:" + album.AlbumSlug}});
},
loadMore() {
if (this.scrollDisabled) return;
@ -147,7 +147,7 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info('All ' + this.results.length + ' albums loaded');
this.$notify.info('All ' + this.results.length + ' albums loaded');
}
});
},
@ -201,9 +201,9 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info(this.results.length + ' albums found');
this.$notify.info(this.results.length + ' albums found');
} else {
this.$alert.info('More than 20 albums found');
this.$notify.info('More than 20 albums found');
this.$nextTick(() => this.$emit("scrollRefresh"));
}
@ -222,7 +222,7 @@
const album = new Album({"AlbumName": name});
album.save().then(() => {
this.$alert.success(name + " created");
this.$notify.success(name + " created");
this.filter.q = "";
this.lastFilter = {};

View file

@ -1,550 +0,0 @@
<template>
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="loadMoreDisabled" infinite-scroll-distance="10">
<v-form ref="form" lazy-validation @submit="formChange" dense>
<v-toolbar flat color="blue-grey lighten-4">
<v-text-field class="pt-3 pr-3"
single-line
label="Search"
prepend-inner-icon="search"
clearable
color="blue-grey"
@click:clear="clearQuery"
v-model="query.q"
@keyup.enter.native="formChange"
></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="Country"
flat solo hide-details
color="blue-grey"
item-value="LocCountryCode"
item-text="LocCountry"
v-model="query.country"
:items="options.countries">
</v-select>
</v-flex>
<v-flex xs12 sm6 md3 pa-2>
<v-select @change="formChange"
label="Camera"
flat solo hide-details
color="blue-grey"
item-value="ID"
item-text="CameraModel"
v-model="query.camera"
:items="options.cameras">
</v-select>
</v-flex>
<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-layout>
</v-card-text>
</v-card>
</v-form>
<v-container fluid>
<v-layout wrap>
<v-flex xs12>
<h1>
<v-text-field label="Album name*" required value="South Africa 2018" solo></v-text-field>
</h1>
</v-flex>
<v-flex xs12>
<v-textarea label="Description" value="Nice photo collection of the last vacation"
solo></v-textarea>
</v-flex>
</v-layout>
<p> In case you want to add photos to this album
--> go to the photos view --> select all photos you want to add
--> click add to album in the bottom right menu and select this album.</p>
<p> In case you want to remove photos from this album
--> select all photos you want to remove
--> click remove in the bottom right menu.</p>
</v-container>
<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
color="yellow accent-4"
>
<v-icon>create_new_folder</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="delete"
>
<v-icon>delete</v-icon>
</v-btn>
</v-speed-dial>
<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>{{ props.item.PhotoTitle }}</td>
<td>{{ props.item.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}</td>
<td>{{ props.item.LocCity }}</td>
<td>{{ props.item.LocCountry }}</td>
<td>{{ props.item.CameraModel }}</td>
<td>{{ props.item.PhotoFavorite ? 'Yes' : 'No' }}</td>
</template>
</v-data-table>
<v-container grid-list-xs fluid class="pa-0" v-if="query.view === 'details'">
<v-card v-if="results.length === 0">
<v-card-title primary-title>
<div>
<h3 class="title mb-3">No photos matched your search</h3>
<div>Try using other terms and search options such as category, country and camera.</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-flex
v-for="(photo, index) in results"
:key="photo.ID"
xs12 sm6 md4 lg3 d-flex
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:dark="photo.selected"
:class="photo.selected ? 'elevation-14 ma-1' : 'elevation-2 ma-2'">
<v-img
:src="photo.getThumbnailUrl('tile_500')"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
style="cursor: pointer"
class="grey lighten-2"
@click="openPhoto(index)"
>
<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-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="bottom: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite
</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
</v-btn>
</v-img>
<v-card-title primary-title class="pa-3">
<div>
<h3 class="subheading mb-2" :title="photo.PhotoTitle">{{ photo.PhotoTitle |
truncate(80) }}</h3>
<div class="caption">
<v-icon size="14">date_range</v-icon>
{{ photo.TakenAt | luxon:format('dd/MM/yyyy hh:mm:ss') }}
<br/>
<v-icon size="14">photo_camera</v-icon>
{{ photo.getCamera() }}
<br/>
<v-icon size="14">location_on</v-icon>
<span :title="photo.getFullLocation()">{{ photo.getLocation() }}</span>
</div>
</div>
</v-card-title>
</v-card>
</v-hover>
</v-flex>
</v-layout>
</v-container>
<v-container grid-list-xs fluid class="pa-0" v-if="query.view === 'tiles'">
<v-card v-if="results.length === 0">
<v-card-title primary-title>
<div>
<h3 class="headline mb-3">No photos matched your search</h3>
<div>Try using other terms and search options such as category, country and camera.</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-flex
v-for="(photo, index) in results"
:key="photo.ID"
xs12 sm6 md3 lg2 d-flex
v-bind:class="{ selected: photo.selected }"
>
<v-hover>
<v-card tile slot-scope="{ hover }"
:dark="photo.selected"
:class="photo.selected ? 'elevation-14 ma-1' : hover ? 'elevation-6 ma-2' : 'elevation-2 ma-2'">
<v-img :src="photo.getThumbnailUrl('tile_500')"
aspect-ratio="1"
class="grey lighten-2"
style="cursor: pointer"
@click="openPhoto(index)"
>
<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-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="bottom: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
</v-btn>
</v-img>
</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>
</div>
</template>
<script>
import Photo from 'model/photo';
export default {
name: 'browse',
props: {},
data() {
dialog: false;
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'] : 'tiles';
const cameras = [{ID: 0, CameraModel: 'All Cameras'}].concat(this.$config.getValue('cameras'));
const countries = [{
LocCountryCode: '',
LocCountry: 'All Countries'
}].concat(this.$config.getValue('countries'));
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: 'tiles', text: 'Tiles'},
{value: 'details', text: 'Details'},
{value: 'list', text: 'List'},
],
'countries': countries,
'cameras': cameras,
'sorting': [
{value: 'newest', text: 'Newest first'},
{value: 'oldest', text: 'Oldest first'},
{value: 'imported', text: 'Recently imported'},
],
},
'listColumns': [
{text: 'Title', value: 'PhotoTitle'},
{text: 'Taken At', value: 'TakenAt'},
{text: 'City', value: 'LocCity'},
{text: 'Country', value: 'LocCountry'},
{text: 'Camera', value: 'CameraModel'},
{text: 'Favorite', value: 'PhotoFavorite'},
],
'view': view,
'loadMoreDisabled': true,
'pageSize': 60,
'offset': 0,
'lastQuery': {},
'submitTimeout': false,
'selected': [],
'dialog': false,
};
},
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.search();
},
openPhoto(index) {
this.$viewer.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;
this.loadMoreDisabled = true;
this.offset += this.pageSize;
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Photo.search(params).then(response => {
this.results = this.results.concat(response.models);
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
}
});
},
search() {
this.loadMoreDisabled = true;
// 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});
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Photo.search(params).then(response => {
this.results = response.models;
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info(this.results.length + ' photos found');
} else {
this.$alert.info('More than 50 photos found');
}
});
}
},
beforeRouteLeave(to, from, next) {
next()
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.search();
},
};
</script>

View file

@ -1,52 +0,0 @@
<template>
<div>
<v-toolbar flat color="blue-grey lighten-4">
<h1>Import</h1>
</v-toolbar>
<v-container fluid>
<v-form>
<p class="md-subheading">
You have two possibilities to get your photos into photoprism.</p>
<h2>Import & Index</h2>
<p>Importing means the photos you upload are renamed (the naming schema you can define in settings), and moved to the originals folder sorted by year and month.
Additionally duplicates are removed and images get tagged and metadata (like location, camera model etc.) will be extracted. In case you have not supported file types
(e.g. videos) within the folder you import --> those are ignored. </p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn" disabled>Import & Index</v-btn>
<input v-show="false" ref="inputUpload" type="file">
<v-flex xs12 sm6 offset-sm3>
<v-card class="card">
<v-card-title primary-title>
<div>
<div>598 JPEG and 432 RAW files found</div>
</div>
</v-card-title>
<v-card-actions>
<v-btn flat color="success">Start</v-btn>
<v-btn flat color="success">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-form>
</v-container>
<v-container fluid>
<v-form>
<h2>Index</h2>
<p>In case you already have a nice folder structure you can only index the photos. Therefore in settings you need to set the base directory to the directory your photos
are in. The index functionality will then just tag the images and extract the metadata.
</p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn" disabled>Index</v-btn>
</v-form>
</v-container>
</div>
</template>
<script>
export default {
name: 'import',
props: {},
data() {
return {}
},
};
</script>

View file

@ -1,57 +0,0 @@
<template>
<div>
<v-toolbar flat color="blue-grey lighten-4">
<h1>Import</h1>
</v-toolbar>
<v-container fluid>
<v-form>
<p class="md-subheading">
You have two possibilities to get your photos into photoprism.</p>
<h2>Import & Index</h2>
<p>Importing means the photos you upload are renamed (the naming schema you can define in settings), and moved to the originals folder sorted by year and month.
Additionally duplicates are removed and images get tagged and metadata (like location, camera model etc.) will be extracted. In case you have not supported file types
(e.g. videos) within the folder you import --> those are ignored. </p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn" disabled>Import & Index</v-btn>
<input v-show="false" ref="inputUpload" type="file">
<v-flex xs12 sm6 offset-sm3>
<v-card class="card">
<v-card-title primary-title>
<div>
<div>Processing image 360 from 1030</div>
</div>
<v-progress-linear
background-color="pink lighten-3"
color="pink lighten-1"
value="35"
></v-progress-linear>
</v-card-title>
<v-card-actions>
<v-btn flat color="success" disabled>Start</v-btn>
<v-btn flat color="success">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-form>
</v-container>
<v-container fluid>
<v-form>
<h2>Index</h2>
<p>In case you already have a nice folder structure you can only index the photos. Therefore in settings you need to set the base directory to the directory your photos
are in. The index functionality will then just tag the images and extract the metadata.
</p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn" disabled>Index</v-btn>
</v-form>
</v-container>
</div>
</template>
<script>
export default {
name: 'import',
props: {},
data() {
return {}
},
};
</script>

View file

@ -86,7 +86,7 @@
staticFilter: Object
},
watch: {
'$route' () {
'$route'() {
const query = this.$route.query;
this.filter.q = query['q'];
@ -121,7 +121,7 @@
},
openLabel(index) {
const label = this.results[index];
this.$router.push({name: 'Photos', query: {q: "label:" + label.LabelSlug}});
this.$router.push({name: "photos", query: {q: "label:" + label.LabelSlug}});
},
loadMore() {
if (this.scrollDisabled) return;
@ -143,7 +143,7 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info('All ' + this.results.length + ' labels loaded');
this.$notify.info('All ' + this.results.length + ' labels loaded');
}
});
},
@ -197,9 +197,9 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info(this.results.length + ' labels found');
this.$notify.info(this.results.length + ' labels found');
} else {
this.$alert.info('More than 20 labels found');
this.$notify.info('More than 20 labels found');
this.$nextTick(() => this.$emit("scrollRefresh"));
}

View file

@ -3,7 +3,8 @@
<v-form ref="form" class="p-photo-import" lazy-validation @submit.prevent="submit" dense>
<v-container fluid>
<p class="subheading">
<span v-if="busy">Importing files from directory...</span>
<span v-if="fileName">Importing {{ fileName }}...</span>
<span v-else-if="busy">Importing files from directory...</span>
<span v-else-if="completed">Done.</span>
<span v-else>Press button to import photos from directory...</span>
</p>
@ -13,7 +14,7 @@
<v-btn
:disabled="busy"
color="blue-grey"
class="white--text ml-0"
class="white--text ml-0 mt-2"
depressed
@click.stop="startImport()"
>
@ -26,7 +27,9 @@
</template>
<script>
import axios from "axios";
import Api from "common/api";
import Axios from "axios";
import Notify from "common/notify";
import Event from "pubsub-js";
export default {
@ -36,31 +39,75 @@
started: false,
busy: false,
completed: 0,
subscriptionId: '',
fileName: '',
source: null,
}
},
methods: {
submit() {
console.log("SUBMIT");
// DO NOTHING
},
startImport() {
this.source = Axios.CancelToken.source();
this.started = Date.now();
this.busy = true;
this.completed = 0;
this.$alert.info("Importing photos...");
this.fileName = '';
const ctx = this;
Notify.blockUI();
axios.post('/api/v1/import').then(function () {
Event.publish("alert.success", "Import complete");
Api.post('import', {}, { cancelToken: this.source.token }).then(function () {
Notify.unblockUI();
ctx.busy = false;
ctx.completed = 100;
}).catch(function () {
Event.publish("alert.error", "Import failed");
ctx.fileName = '';
}).catch(function (e) {
Notify.unblockUI();
if (Axios.isCancel(e)) {
// run in background
return
}
Notify.error("Import failed");
ctx.busy = false;
ctx.completed = 0;
ctx.fileName = '';
});
},
}
handleEvent(ev, data) {
if(this.source) {
this.source.cancel('run in background');
this.source = null;
Notify.unblockUI();
}
const type = ev.split('.')[1];
switch (type) {
case 'file':
this.busy = true;
this.completed = 0;
this.fileName = data.baseName;
break;
case 'completed':
this.busy = false;
this.completed = 100;
this.fileName = '';
break;
default:
console.log(data)
}
},
},
created() {
this.subscriptionId = Event.subscribe('import', this.handleEvent);
},
destroyed() {
Event.unsubscribe(this.subscriptionId);
},
};
</script>

View file

@ -3,7 +3,8 @@
<v-form ref="form" class="p-photo-index" lazy-validation @submit.prevent="submit" dense>
<v-container fluid>
<p class="subheading">
<span v-if="busy">Re-indexing existing files and photos...</span>
<span v-if="fileName">Indexing {{ fileName }}...</span>
<span v-else-if="busy">Re-indexing existing files and photos...</span>
<span v-else-if="completed">Done.</span>
<span v-else>Press button to re-index existing files and photos...</span>
</p>
@ -13,7 +14,7 @@
<v-btn
:disabled="busy"
color="blue-grey"
class="white--text ml-0"
class="white--text ml-0 mt-2"
depressed
@click.stop="startIndexing()"
>
@ -26,7 +27,9 @@
</template>
<script>
import axios from "axios";
import Api from "common/api";
import Axios from "axios";
import Notify from "common/notify";
import Event from "pubsub-js";
export default {
@ -36,31 +39,75 @@
started: false,
busy: false,
completed: 0,
subscriptionId: '',
fileName: '',
source: null,
}
},
methods: {
submit() {
console.log("SUBMIT");
// DO NOTHING
},
startIndexing() {
this.source = Axios.CancelToken.source();
this.started = Date.now();
this.busy = true;
this.completed = 0;
this.$alert.info("Indexing photos...");
this.fileName = '';
const ctx = this;
Notify.blockUI();
axios.post('/api/v1/index').then(function () {
Event.publish("alert.success", "Indexing complete");
Api.post('index', {}, { cancelToken: this.source.token }).then(function () {
Notify.unblockUI();
ctx.busy = false;
ctx.completed = 100;
}).catch(function () {
Event.publish("alert.error", "Indexing failed");
ctx.fileName = '';
}).catch(function (e) {
Notify.unblockUI();
if (Axios.isCancel(e)) {
// run in background
return
}
Notify.error("Indexing failed");
ctx.busy = false;
ctx.completed = 0;
ctx.fileName = '';
});
},
}
handleEvent(ev, data) {
if(this.source) {
this.source.cancel('run in background');
this.source = null;
Notify.unblockUI();
}
const type = ev.split('.')[1];
switch (type) {
case 'file':
this.busy = true;
this.completed = 0;
this.fileName = data.fileName;
break;
case 'completed':
this.busy = false;
this.completed = 100;
this.fileName = '';
break;
default:
console.log(data)
}
},
},
created() {
this.subscriptionId = Event.subscribe('index', this.handleEvent);
},
destroyed() {
Event.unsubscribe(this.subscriptionId);
},
};
</script>

View file

@ -16,7 +16,7 @@
<v-btn
:disabled="busy"
color="blue-grey"
class="white--text ml-0"
class="white--text ml-0 mt-2"
depressed
@click.stop="uploadDialog()"
>
@ -29,8 +29,8 @@
</template>
<script>
import axios from "axios";
import Event from "pubsub-js";
import Api from "common/api";
import Notify from "common/notify";
export default {
name: 'p-tab-upload',
@ -48,7 +48,7 @@
},
methods: {
submit() {
console.log("SUBMIT");
// DO NOTHING
},
uploadDialog() {
this.$refs.upload.click();
@ -67,9 +67,8 @@
return
}
this.$alert.info("Uploading photos...");
Event.publish("ajax.start");
Notify.info("Uploading photos...");
Notify.blockUI();
async function performUpload(ctx) {
for (let i = 0; i < ctx.selected.length; i++) {
@ -80,7 +79,7 @@
formData.append('files', file);
await axios.post('/api/v1/upload/' + ctx.started,
await Api.post('upload/' + ctx.started,
formData,
{
headers: {
@ -90,7 +89,7 @@
).then(function () {
ctx.completed = Math.round((ctx.current / ctx.total) * 100);
}).catch(function () {
Event.publish("alert.error", "Upload failed");
Notify.error("Upload failed");
});
}
}
@ -99,17 +98,17 @@
this.indexing = true;
const ctx = this;
axios.post('/api/v1/import/upload/' + this.started).then(function () {
Event.publish("alert.success", "Upload complete");
Api.post('import/upload/' + this.started).then(function () {
Notify.unblockUI();
Notify.success("Upload complete");
ctx.busy = false;
ctx.indexing = false;
}).catch(function () {
Event.publish("alert.error", "Failure while importing uploaded files");
Notify.unblockUI();
Notify.error("Failure while importing uploaded files");
ctx.busy = false;
ctx.indexing = false;
});
Event.publish("ajax.end");
});
},
}

View file

@ -0,0 +1,56 @@
<template>
<div class="p-page p-page-login">
<v-toolbar flat color="blue-grey lighten-4">
<v-toolbar-title>Login</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-container class="pt-5">
<p class="subheading">
<span>Please enter the admin password to proceed...</span>
</p>
<v-form ref="form" autocomplete="off" class="p-form-login" @submit.prevent="login" dense>
<v-text-field
label="Password"
color="grey"
v-model="password"
solo
flat
:append-icon="showPassword ? 'visibility' : 'visibility_off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = !showPassword"
></v-text-field>
<v-btn color="blue-grey"
class="white--text ml-0"
depressed
@click.stop="login">
Sign in
<v-icon right dark>vpn_key</v-icon>
</v-btn>
</v-form>
</v-container>
</div>
</template>
<script>
export default {
name: 'login',
data() {
return {
showPassword: false,
password: '',
nextUrl: this.$route.params.nextUrl ? this.$route.params.nextUrl : "/",
};
},
methods: {
login() {
this.$session.login('admin', this.password).then(
() => {
this.$router.push(this.nextUrl);
}
);
},
}
};
</script>

View file

@ -86,15 +86,15 @@
const photo = this.results[index];
if (photo.PhotoLat && photo.PhotoLong) {
this.$router.push({name: 'Places', query: {lat: photo.PhotoLat, long: photo.PhotoLong}});
this.$router.push({name: "places", query: {lat: photo.PhotoLat, long: photo.PhotoLong}});
} else if (photo.LocName) {
this.$router.push({name: 'Places', query: {q: photo.LocName}});
this.$router.push({name: "places", query: {q: photo.LocName}});
} else if (photo.LocCity) {
this.$router.push({name: 'Places', query: {q: photo.LocCity}});
this.$router.push({name: "places", query: {q: photo.LocCity}});
} else if (photo.LocCountry) {
this.$router.push({name: 'Places', query: {q: photo.LocCountry}});
this.$router.push({name: "places", query: {q: photo.LocCountry}});
} else {
this.$router.push({name: 'Places', query: {q: photo.CountryName}});
this.$router.push({name: "places", query: {q: photo.CountryName}});
}
},
openPhoto(index) {
@ -120,7 +120,7 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
this.$notify.info('All ' + this.results.length + ' photos loaded');
}
});
},
@ -182,9 +182,9 @@
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$alert.info(this.results.length + ' photos found');
this.$notify.info(this.results.length + ' photos found');
} else {
this.$alert.info('More than 50 photos found');
this.$notify.info('More than 50 photos found');
this.$nextTick(() => this.$emit("scrollRefresh"));
}

View file

@ -1,399 +0,0 @@
<template>
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="loadMoreDisabled" infinite-scroll-distance="10">
<v-container fluid>
<v-btn @click.stop="dialog= true">Dialog</v-btn>
<v-dialog v-model="dialog" dark fullscreen transition="dialog-bottom-transition">
<v-card dark>
<v-layout row wrap justify-center class="px-4 py-5">
<v-flex md8 xs12>
<v-card dark flat>
<v-img src="/static/img/tagcloud.jpg" aspect-ratio="1" class="mb-5 mx-5"></v-img>
</v-card>
</v-flex>
<v-flex md4 xs12>
<v-card dark flat>
<v-card-text>
<form>
<v-text-field
v-model="Title"
label="Title"
placeholder="Tagcloud"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="artist"
label="Artist"
placeholder="Unknown"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="taken"
label="Taken at"
placeholder="02/02/19 00:02"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="location"
label="Location"
placeholder="Berlin"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="camera"
label="Camera"
placeholder="Iphone 5S"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="lense"
label="Lense"
placeholder="xxx"
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="aperture"
label="Aperture"
placeholder=""
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="focal"
label="Focal Length"
placeholder=""
></v-text-field>
<v-spacer></v-spacer>
<v-text-field
v-model="color"
label="Color"
placeholder="unknown"
></v-text-field>
<v-spacer></v-spacer>
</form>
<v-combobox
v-model="model2"
:filter="filter"
:hide-no-data="!search"
:items="items"
:search-input.sync="search"
hide-selected
label="Tags"
multiple
small-chips
>
<template v-slot:no-data>
<v-list-tile>
<span class="subheading">Create</span>
<v-chip
color= "blue"
label
small
>
search
</v-chip>
</v-list-tile>
</template>
<template v-slot:selection="{ item, parent, selected }">
<v-chip
v-if="item === Object(item)"
color= "primary"
:selected="selected"
label
small
>
<span class="pr-2">
item text
</span>
<v-icon
small
@click="parent.selectItem(item)"
>close</v-icon>
</v-chip>
</template>
<template v-slot:item="{ index, item }">
<v-list-tile-content>
<v-text-field
v-if="editing === item"
v-model="editing.text"
autofocus
flat
background-color="transparent"
hide-details
solo
@keyup.enter="edit(index, item)"
></v-text-field>
<v-chip
v-else
color="red"
dark
label
small
>
item text
</v-chip>
</v-list-tile-content>
</template>
</v-combobox>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script>
import Photo from 'model/photo';
export default {
name: 'photos',
props: {},
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'] : 'tiles';
const cameras = [{ID: 0, CameraModel: 'All Cameras'}].concat(this.$config.getValue('cameras'));
const countries = [{
LocCountryCode: '',
LocCountry: 'All Countries'
}].concat(this.$config.getValue('countries'));
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: 'tiles', text: 'Tiles'},
{value: 'details', text: 'Details'},
{value: 'list', text: 'List'},
],
'countries': countries,
'cameras': cameras,
'sorting': [
{value: 'newest', text: 'Newest first'},
{value: 'oldest', text: 'Oldest first'},
{value: 'imported', text: 'Recently imported'},
],
},
'listColumns': [
{text: 'Title', value: 'PhotoTitle'},
{text: 'Taken At', value: 'TakenAt'},
{text: 'City', value: 'LocCity'},
{text: 'Country', value: 'LocCountry'},
{text: 'Camera', value: 'CameraModel'},
{text: 'Favorite', value: 'PhotoFavorite'},
],
'view': view,
'loadMoreDisabled': true,
'pageSize': 60,
'offset': 0,
'lastQuery': {},
'submitTimeout': false,
'selected': [],
'dialog' : true,
'dialog2': false,
'search': null,
'activator': null,
'attach': null,
'colors': ['green', 'purple', 'indigo', 'primary', 'success', 'orange'],
'color': '',
'editing': null,
'index': -1,
'items': [
{ header: 'Select a tag or create one' },
{text: 'Cat', color: 'primary'},
{text: 'Sun', color: 'red'},
{text: 'Dog', color: 'primary'},
{text: 'Holiday', color: 'primary'},
{text: 'Tiger', color: 'primary'},
{text: 'Soup', color: 'primary'},
{text: 'Night', color: 'primary'},
{text: 'Table', color: 'primary'},
{text: 'Apple', color: 'primary'},
{text: 'Frog', color: 'primary'},
],
'nonce': 1,
'menu': false,
'model': [
],
'model2': [
{text: 'Cat', color: 'primary'},
{text: 'Dog', color: 'primary'},
{text: 'Holiday', color: 'primary'},
{text: 'Tiger', color: 'primary'},
{text: 'Soup', color: 'primary'},
{text: 'Night', color: 'primary'},
{text: 'Table', color: 'primary'},
{text: 'Apple', color: 'primary'},
{text: 'Frog', color: 'primary'},
],
'x': 0,
'y': 0,
};
},
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.search();
},
openPhoto(index) {
this.$viewer.show(this.results, index)
},
loadMore() {
if (this.loadMoreDisabled) return;
this.loadMoreDisabled = true;
this.offset += this.pageSize;
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.lastQuery);
Photo.search(params).then(response => {
this.results = this.results.concat(response.models);
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info('All ' + this.results.length + ' photos loaded');
}
});
},
search() {
this.loadMoreDisabled = true;
// 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});
const params = {
count: this.pageSize,
offset: this.offset,
};
Object.assign(params, this.query);
Photo.search(params).then(response => {
this.results = response.models;
this.loadMoreDisabled = (response.models.length < this.pageSize);
if (this.loadMoreDisabled) {
this.$alert.info(this.results.length + ' photos found');
} else {
this.$alert.info('More than 50 photos found');
}
});
}
},
beforeRouteLeave(to, from, next) {
next()
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.search();
},
};
</script>

View file

@ -82,14 +82,14 @@
this.search();
},
currentPositionError(error) {
this.$alert.warning(error.message);
this.$notify.warning(error.message);
},
currentPosition() {
if ("geolocation" in navigator) {
this.$alert.success('Finding your position...');
this.$notify.success('Finding your position...');
navigator.geolocation.getCurrentPosition(this.currentPositionSuccess, this.currentPositionError);
} else {
this.$alert.warning('Geolocation is not available');
this.$notify.warning('Geolocation is not available');
}
},
formChange() {
@ -156,7 +156,7 @@
}
if (photos.length === 0) {
this.$alert.warning('No locations found');
this.$notify.warning('No locations found');
return;
}
@ -169,13 +169,13 @@
});
if (photos.length > 100) {
this.$alert.info('More than 100 photos found');
this.$notify.info('More than 100 photos found');
} else {
this.$alert.info(photos.length + ' photos found');
this.$notify.info(photos.length + ' photos found');
}
},
updateQuery() {
this.$router.replace({query: this.query});
this.$router.replace({query: this.query}).catch(err => {});
if(this.query.lat && this.query.long) {
this.position = L.latLng(this.query.lat, this.query.long);
@ -194,7 +194,7 @@
this.updateQuery();
this.$router.replace({query: this.query});
this.$router.replace({query: this.query}).catch(err => {});
const params = {
count: this.pageSize,
@ -206,7 +206,7 @@
Photo.search(params).then(response => {
if (!response.models.length) {
this.$alert.warning('No photos found');
this.$notify.warning('No photos found');
return;
}

View file

@ -1,29 +1,38 @@
<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 class="p-page p-page-settings">
<v-tabs
v-model="active"
flat
grow
color="blue-grey lighten-4"
slider-color="blue-grey darken-1"
height="64"
>
<v-tab id="tab-upload">
General
</v-tab>
<v-tab-item>
<p-tab-general></p-tab-general>
</v-tab-item>
</v-tabs>
</div>
</template>
<script>
import tabGeneral from "pages/settings/general.vue";
export default {
name: 'todo',
data() {
return {};
name: 'p-page-settings',
components: {
'p-tab-general': tabGeneral,
},
data() {
return {
readonly: this.$config.getValue("readonly"),
active: 0,
}
},
methods: {
},
methods: {}
};
</script>

View file

@ -0,0 +1,68 @@
<template>
<div class="p-tab p-tab-general">
<v-container fluid>
<v-form ref="form" class="p-form-settings" lazy-validation @submit.prevent="save" dense>
<v-layout wrap align-center>
<v-flex xs12 sm6 class="pr-3">
<v-select
:items="languages"
label="Language"
color="blue-grey"
v-model="settings.language"
flat
></v-select>
</v-flex>
<v-flex xs12 sm6 class="pr-3">
<v-select
:items="themes"
label="Theme"
color="blue-grey"
v-model="settings.theme"
flat
></v-select>
</v-flex>
</v-layout>
<v-btn color="blue-grey"
class="white--text ml-0 mt-2"
depressed
@click.stop="save">
Save
<v-icon right dark>save</v-icon>
</v-btn>
</v-form>
</v-container>
</div>
</template>
<script>
import Settings from "model/settings";
export default {
name: 'p-tab-general',
data() {
return {
readonly: this.$config.getValue("readonly"),
active: 0,
settings: new Settings(this.$config.values.settings),
list: {},
themes: [{text: "Dark", value: "dark"}, {text: "Light", value: "light"}],
languages: [{text: "English", value: "en"}],
};
},
methods: {
load() {
this.settings.load().then((r) => { this.list = r.getValues(); });
},
save() {
this.settings.save().then(() => {
this.$notify.info("Settings saved");
})
},
},
created() {
this.load();
},
};
</script>

View file

@ -2,7 +2,7 @@
<div id="photoprism">
<p-loading-bar height="4"></p-loading-bar>
<p-alert></p-alert>
<p-notify></p-notify>
<v-app>
<p-navigation></p-navigation>
@ -22,14 +22,6 @@
export default {
name: 'photoprism',
computed: {},
methods: {
login() {
// this.$refs.loginDialog.open();
},
logout() {
this.$session.logout();
},
},
methods: {},
};
</script>

View file

@ -7,80 +7,87 @@ import People from "pages/people.vue";
import Library from "pages/library.vue";
import Share from "pages/share.vue";
import Settings from "pages/settings.vue";
import Login from "pages/login.vue";
import Todo from "pages/todo.vue";
export default [
{
name: "Home",
name: "home",
path: "/",
redirect: "/photos",
},
{
name: "Photos",
name: "login",
path: "/login",
component: Login,
meta: {area: "Login"},
},
{
name: "photos",
path: "/photos",
component: Photos,
meta: {area: "Photos"},
},
{
name: "Albums",
name: "albums",
path: "/albums",
component: Albums,
meta: {area: "Albums"},
},
{
name: "Favorites",
name: "favorites",
path: "/favorites",
component: Photos,
meta: {area: "Favorites"},
props: {staticFilter: {favorites: true}},
},
{
name: "Places",
name: "places",
path: "/places",
component: Places,
meta: {area: "Places"},
},
{
name: "Labels",
name: "labels",
path: "/labels",
component: Labels,
meta: {area: "Labels"},
},
{
name: "Events",
name: "events",
path: "/events",
component: Events,
meta: {area: "Events"},
},
{
name: "People",
name: "people",
path: "/people",
component: People,
meta: {area: "People"},
},
{
name: "Filters",
name: "filters",
path: "/filters",
component: Todo,
meta: {area: "Filters"},
},
{
name: "Library",
name: "library",
path: "/library",
component: Library,
meta: {area: "Library"},
meta: {area: "Library", auth: true},
},
{
name: "Share",
name: "share",
path: "/share",
component: Share,
meta: {area: "Share"},
meta: {area: "Share", auth: true},
},
{
name: "Settings",
name: "settings",
path: "/settings",
component: Settings,
meta: {area: "Settings"},
meta: {area: "Settings", auth: true},
},
{
path: "*", redirect: "/photos",

5
frontend/src/session.js Normal file
View file

@ -0,0 +1,5 @@
import Session from "common/session";
const session = new Session(window.localStorage);
export default session;

View file

@ -20,8 +20,4 @@ test('Navigate', async t => {
await t
.click('a[href="/labels"]')
.expect(Selector('div.p-page-labels').exists, {timeout: 5000}).ok();
await page.openNav();
await t
.click('a[href="/library"]')
.expect(Selector('div.p-tab-upload').exists, {timeout: 5000}).ok();
});

View file

@ -53,18 +53,4 @@ describe("common/config", () => {
const result = config.getValue("city");
assert.equal(result, "Berlin");
});
it("should pull from server", async() => {
mock.onGet("config").reply(200, {fromServer: "yes"});
const storage = window.localStorage;
const values = {name: "testConfig", year: "2300"};
const config = new Config(storage, values);
const result = config.getValues();
assert.equal(result.name, "testConfig");
assert.equal(config.values.fromServer, undefined);
await config.pullFromServer();
assert.equal(config.values.fromServer, "yes");
mock.reset();
});
});
});

View file

@ -1,32 +1,32 @@
import Alert from "common/alert";
import Notify from "common/notify";
let sinon = require("sinon");
describe("common/alert", () => {
it("should call alert.info", () => {
let spy = sinon.spy(Alert, "info");
Alert.info("message");
let spy = sinon.spy(Notify, "info");
Notify.info("message");
sinon.assert.calledOnce(spy);
spy.resetHistory();
});
it("should call alert.warning", () => {
let spy = sinon.spy(Alert, "warning");
Alert.warning("message");
let spy = sinon.spy(Notify, "warning");
Notify.warning("message");
sinon.assert.calledOnce(spy);
spy.resetHistory();
});
it("should call alert.error", () => {
let spy = sinon.spy(Alert, "error");
Alert.error("message");
let spy = sinon.spy(Notify, "error");
Notify.error("message");
sinon.assert.calledOnce(spy);
spy.resetHistory();
});
it("should call alert.success", () => {
let spy = sinon.spy(Alert, "success");
Alert.success("message");
let spy = sinon.spy(Notify, "success");
Notify.success("message");
sinon.assert.calledOnce(spy);
spy.resetHistory();
});
});
});

View file

@ -35,15 +35,15 @@ describe('common/session', () => {
it('should set, get and delete user', () => {
const storage = window.localStorage;
const session = new Session(storage);
assert.equal(session.user.ID, undefined);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
assert.equal(session.user, null);
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
assert.equal(session.user.userFirstName, "Max");
assert.equal(session.user.userRole, "admin");
assert.equal(session.user.FirstName, "Max");
assert.equal(session.user.Role, "admin");
const result = session.getUser();
assert.equal(result.ID, 5);
assert.equal(result.userEmail, "test@test.com");
assert.equal(result.Email, "test@test.com");
session.deleteUser();
assert.equal(session.user, null);
});
@ -51,12 +51,12 @@ describe('common/session', () => {
it('should get user email', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.getEmail();
assert.equal(result, "test@test.com");
const values2 = { userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values2 = { FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user2 = new User(values2);
session.setUser(user2);
const result2 = session.getEmail();
@ -67,12 +67,12 @@ describe('common/session', () => {
it('should get user firstname', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.getFirstName();
assert.equal(result, "Max");
const values2 = { userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values2 = { FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user2 = new User(values2);
session.setUser(user2);
const result2 = session.getFirstName();
@ -83,12 +83,12 @@ describe('common/session', () => {
it('should get user full name', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.getFullName();
assert.equal(result, "Max Last");
const values2 = { userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values2 = { FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user2 = new User(values2);
session.setUser(user2);
const result2 = session.getFullName();
@ -99,7 +99,7 @@ describe('common/session', () => {
it('should test whether user is set', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.isUser();
@ -110,7 +110,7 @@ describe('common/session', () => {
it('should test whether user is admin', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.isAdmin();
@ -121,7 +121,7 @@ describe('common/session', () => {
it('should test whether user is anonymous', () => {
const storage = window.localStorage;
const session = new Session(storage);
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
session.setUser(user);
const result = session.isAnonymous();
@ -131,7 +131,7 @@ describe('common/session', () => {
it('should test login and logout', async() => {
mock
.onPost("session").reply(200, {token: "8877", user: {email: "test@test.com", password: "passwd"}})
.onPost("session").reply(200, {token: "8877", user: {ID: 1, Email: "test@test.com"}})
.onDelete("session/8877").reply(200);
const storage = window.localStorage;
const session = new Session(storage);
@ -139,10 +139,10 @@ describe('common/session', () => {
assert.equal(session.storage.user, undefined);
await session.login("test@test.com", "passwd");
assert.equal(session.session_token, 8877);
assert.equal(session.storage.user, "{\"email\":\"test@test.com\",\"password\":\"passwd\"}");
assert.equal(session.storage.user, '{"ID":1,"Email":"test@test.com"}');
await session.logout();
assert.equal(session.session_token, null);
mock.reset();
});
});
});

View file

@ -9,14 +9,14 @@ describe("model/user", () => {
const mock = new MockAdapter(Api);
it("should get entity name", () => {
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
const result = user.getEntityName();
assert.equal(result, "Max Last");
});
it("should get id", () => {
const values = {ID: 5, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 5, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
const result = user.getId();
assert.equal(result, 5);
@ -34,7 +34,7 @@ describe("model/user", () => {
it("should get register form", async() => {
mock.onAny("users/52/register").reply(200, "registerForm");
const values = {ID: 52, userFirstName: "Max"};
const values = {ID: 52, FirstName: "Max"};
const user = new User(values);
const result = await user.getRegisterForm();
assert.equal(result.definition, "registerForm");
@ -43,7 +43,7 @@ describe("model/user", () => {
it("should get profile form", async() => {
mock.onAny("users/53/profile").reply(200, "profileForm");
const values = {ID: 53, userFirstName: "Max"};
const values = {ID: 53, FirstName: "Max"};
const user = new User(values);
const result = await user.getProfileForm();
assert.equal(result.definition, "profileForm");
@ -52,20 +52,20 @@ describe("model/user", () => {
it("should get change password", async() => {
mock.onPut("users/54/password").reply(200, {password: "old", new_password: "new"});
const values = {ID: 54, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
const values = {ID: 54, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
const result = await user.changePassword("old", "new");
assert.equal(result.new_password, "new");
});
it("should save profile", async() => {
mock.onPost("users/55/profile").reply(200, {userFirstName: "MaxNew", userLastName: "LastNew"});
const values = {ID: 55, userFirstName: "Max", userLastName: "Last", userEmail: "test@test.com", userRole: "admin"};
mock.onPost("users/55/profile").reply(200, {FirstName: "MaxNew", LastName: "LastNew"});
const values = {ID: 55, FirstName: "Max", LastName: "Last", Email: "test@test.com", Role: "admin"};
const user = new User(values);
assert.equal(user.userFirstName, "Max");
assert.equal(user.userLastName, "Last");
assert.equal(user.FirstName, "Max");
assert.equal(user.LastName, "Last");
await user.saveProfile();
assert.equal(user.userFirstName, "MaxNew");
assert.equal(user.userLastName, "LastNew");
assert.equal(user.FirstName, "MaxNew");
assert.equal(user.LastName, "LastNew");
});
});
});

View file

@ -199,7 +199,7 @@ const config = {
// No sourcemap for production
if (isDev) {
const devToolPlugin = new webpack.SourceMapDevToolPlugin({
filename: "[name].map",
filename: "[file].map",
});
config.plugins.push(devToolPlugin);

16
go.mod
View file

@ -13,11 +13,14 @@ require (
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f
github.com/dsoprea/go-logging v0.0.0-20190409182557-13b4fff49234 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.3.0
github.com/go-errors/errors v1.0.1 // indirect
github.com/golang/geo v0.0.0-20190507233405-a0e886e97a51 // indirect
github.com/gorilla/websocket v1.4.0 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/google/go-cmp v0.3.1 // indirect
github.com/gorilla/websocket v1.4.1
github.com/gosimple/slug v1.5.0
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.5.1 // indirect
@ -25,6 +28,7 @@ require (
github.com/json-iterator/go v1.1.5 // indirect
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/leandro-lugaresi/hub v1.1.0
github.com/lucasb-eyer/go-colorful v1.0.2
github.com/mattn/go-isatty v0.0.4 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@ -32,6 +36,7 @@ require (
github.com/montanaflynn/stats v0.0.0-20181214052348-945b007cb92f // indirect
github.com/myesui/uuid v1.0.0 // indirect
github.com/opentracing/opentracing-go v1.0.2
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pingcap/errors v0.11.1
github.com/pingcap/goleveldb v0.0.0-20171020122428-b9ff6c35079e // indirect
github.com/pingcap/parser v0.0.0-20190529073816-0550d84c65ad
@ -55,16 +60,19 @@ require (
github.com/uber-go/atomic v1.3.2 // indirect
github.com/uber/jaeger-client-go v2.15.0+incompatible // indirect
github.com/uber/jaeger-lib v1.5.0 // indirect
github.com/ugorji/go v1.1.7 // indirect
github.com/unrolled/render v0.0.0-20181210145518-4c664cb3ad2f // indirect
github.com/urfave/cli v1.20.0
go.uber.org/atomic v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20190424203555-c05e17bb3b2d // indirect
golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b // indirect
golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect
golang.org/x/sys v0.0.0-20190424175732-18eb32c0e2f0 // indirect
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a // indirect
golang.org/x/text v0.3.1 // indirect
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v8 v8.18.2 // indirect
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce // indirect
gopkg.in/stretchr/testify.v1 v1.2.2 // indirect
gopkg.in/ugjka/go-tz.v2 v2.0.8
gopkg.in/yaml.v2 v2.2.2
)

45
go.sum
View file

@ -59,11 +59,6 @@ github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJ
github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ=
github.com/djherbis/times v1.1.0 h1:NFhBDODme0XNX+/5ETW9qL6v3Ty57psiXIQBrzzg44E=
github.com/djherbis/times v1.1.0/go.mod h1:CGMZlo255K5r4Yw0b9RRfFQpM2y7uOmxg4jm9HsaVf8=
github.com/dsoprea/go-exif v0.0.0-20190527162249-12b8993a44a567ccf02080c8e9cd0885c92a8e7a/go.mod h1:DmMpU91/Ax6BAwoRkjgRCr2rmgEgS4tsmatfV7M+U+c=
github.com/dsoprea/go-exif v0.0.0-20190527162249-17eaca42337c h1:XIvtrgkakwGk0WzvHVKpQQxCR3MTZEqgOpTymwg+stY=
github.com/dsoprea/go-exif v0.0.0-20190527162249-17eaca42337c/go.mod h1:DmMpU91/Ax6BAwoRkjgRCr2rmgEgS4tsmatfV7M+U+c=
github.com/dsoprea/go-exif v0.0.0-20190624162249-12b8993a44a567ccf02080c8e9cd0885c92a8e7a h1:ZBQj1uonO+8tjOSroKkZofHGrSAG0l+hMIOiDw5Zxiw=
github.com/dsoprea/go-exif v0.0.0-20190624162249-12b8993a44a567ccf02080c8e9cd0885c92a8e7a/go.mod h1:DmMpU91/Ax6BAwoRkjgRCr2rmgEgS4tsmatfV7M+U+c=
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f h1:vqfYiZ+xF0xJvl9SZ1kovmMgKjaGZIz/Hn8JDQdyd9A=
github.com/dsoprea/go-exif v0.0.0-20190901173045-3ce78807c90f/go.mod h1:DmMpU91/Ax6BAwoRkjgRCr2rmgEgS4tsmatfV7M+U+c=
github.com/dsoprea/go-logging v0.0.0-20190409182557-13b4fff49234 h1:WPgjL7Z/Cn2a0kEVtwCqh8BqYjmY8RPGvor/jQZVmzk=
@ -84,8 +79,8 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
@ -111,13 +106,19 @@ github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20161217183710-316fb6d3f031/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
@ -128,8 +129,8 @@ github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51
github.com/gorilla/mux v0.0.0-20170228224354-599cba5e7b61/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gosimple/slug v1.5.0 h1:AIIjgCjHcLpX8LzM2NpG4QGW9kUfqv0OLiFRfPv/H3E=
github.com/gosimple/slug v1.5.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20171020063731-82921fcf811d/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@ -166,6 +167,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leandro-lugaresi/hub v1.1.0 h1:yHYA0WsMYaJd+I6J24nYlCP2CFD4RTnhaHCRmKjv3q4=
github.com/leandro-lugaresi/hub v1.1.0/go.mod h1:IVKrfZTYfU1SbWCGQMHNGYdW4j1Pl7Cg8gr6sSeT/84=
github.com/lib/pq v1.1.0 h1:/5u4a+KGJptBRqGzPvYQL9p0d/tPR4S31+Tnzj9lEO4=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lucasb-eyer/go-colorful v1.0.2 h1:mCMFu6PgSozg9tDNMMK3g18oJBX7oYGrC09mS6CXfO4=
@ -204,6 +207,8 @@ github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKw
github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml v1.3.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pingcap/check v0.0.0-20190102082844-67f458068fc8 h1:USx2/E1bX46VG32FIw034Au6seQ2fY9NEILmNh/UlQg=
@ -256,12 +261,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446 h1:/NRJ5vAYoqz+7sG51ubIDHXeWO8DlTSrToPu6q11ziA=
github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
github.com/remyoudompheng/bigfft v0.0.0-20190512091148-babf20351dd7 h1:FUL3b97ZY2EPqg2NbXKuMHs5pXJB9hjj1fDHnF2vl28=
github.com/remyoudompheng/bigfft v0.0.0-20190512091148-babf20351dd7/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20190806203942-babf20351dd7e3ac320adedbbe5eb311aec8763c h1:eED6LswgZ3TfAl9fb+L2TfdSlXpYdg21iWZMdHuoSks=
github.com/remyoudompheng/bigfft v0.0.0-20190806203942-babf20351dd7e3ac320adedbbe5eb311aec8763c/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sevlyar/go-daemon v0.1.5 h1:Zy/6jLbM8CfqJ4x4RPr7MJlSKt90f00kNM1D401C+Qk=
@ -284,8 +285,6 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tensorflow/tensorflow v1.13.1 h1:ygn0+ztXusm6RGVP4Od5IF+8h5sAgD5qbeTvqYyMnjo=
github.com/tensorflow/tensorflow v1.13.1/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
github.com/tensorflow/tensorflow v1.14.0 h1:g0W2+f/RybcvmrTjPLTwXkfr/BsDGUd8FKT6ZzojOMo=
github.com/tensorflow/tensorflow v1.14.0/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
github.com/tmc/grpc-websocket-proxy v0.0.0-20171017195756-830351dc03c6 h1:lYIiVDtZnyTWlNwiAxLj0bbpTcx1BWCFhXjfsvmPdNc=
@ -305,6 +304,10 @@ github.com/uber/jaeger-lib v1.5.0 h1:OHbgr8l656Ub3Fw5k9SWnBfIEwvoHQ+W2y+Aa9D1Uyo
github.com/uber/jaeger-lib v1.5.0/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go v1.1.1 h1:gmervu+jDMvXTbcHQ0pd2wee85nEoE0BsVyEuzkfK8w=
github.com/ugorji/go v1.1.1/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/unrolled/render v0.0.0-20171102162132-65450fb6b2d3/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg=
github.com/unrolled/render v0.0.0-20181210145518-4c664cb3ad2f h1:+feYJlxPM00jEkdybexHiwIIOVuClwTEbh1WLiNr0mk=
github.com/unrolled/render v0.0.0-20181210145518-4c664cb3ad2f/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg=
@ -316,6 +319,8 @@ github.com/yookoala/realpath v1.0.0/go.mod h1:gJJMA9wuX7AcqLy1+ffPatSCySA1FQ2S8Y
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.uber.org/atomic v1.3.2 h1:2Oa65PReHzfn29GpvgsYwloV9AVFHPDk8tYxt2c2tr4=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
@ -350,8 +355,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTm
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -359,8 +362,8 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190424175732-18eb32c0e2f0 h1:V+O002es++Mnym06Rj/S6Fl7VCsgRBgVDGb/NoZVHUg=
golang.org/x/sys v0.0.0-20190424175732-18eb32c0e2f0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -369,6 +372,8 @@ golang.org/x/text v0.3.1/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181105230042-78dc5bac0cac h1:0Nb35Izc6T6Yz1iGmRc4cg14cxRaFjbjD4hWFI6JNJ8=
@ -408,6 +413,8 @@ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M=
gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/ugjka/go-tz.v2 v2.0.8 h1:EmQ1tY6aa9upe1EDqyPdyHgM9STimr2fw7+b/CuMQ94=

View file

@ -13,8 +13,6 @@ import (
"github.com/photoprism/photoprism/internal/forms"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
)
// GET /api/v1/albums
@ -36,8 +34,8 @@ func GetAlbums(router *gin.RouterGroup, conf *config.Config) {
return
}
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.Header("X-Result-Count", strconv.Itoa(form.Count))
c.Header("X-Result-Offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, result)
})
@ -50,6 +48,11 @@ type CreateAlbumParams struct {
// POST /api/v1/albums
func CreateAlbum(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
var params CreateAlbumParams
if err := c.BindJSON(&params); err != nil {
@ -75,6 +78,11 @@ func CreateAlbum(router *gin.RouterGroup, conf *config.Config) {
// uuid: string Album UUID
func LikeAlbum(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums/:uuid/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
album, err := search.FindAlbumByUUID(c.Param("uuid"))
@ -97,6 +105,11 @@ func LikeAlbum(router *gin.RouterGroup, conf *config.Config) {
// uuid: string Album UUID
func DislikeAlbum(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/albums/:uuid/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
album, err := search.FindAlbumByUUID(c.Param("uuid"))

View file

@ -8,24 +8,24 @@ import (
func TestGetAlbums(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, ctx := NewApiTest()
GetAlbums(router, ctx)
app, router, conf := NewApiTest()
GetAlbums(router, conf)
result := PerformRequest(app, "GET", "/api/v1/albums?count=10")
assert.Equal(t, http.StatusOK, result.Code)
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
GetAlbums(router, ctx)
app, router, conf := NewApiTest()
GetAlbums(router, conf)
result := PerformRequest(app, "GET", "/api/v1/albums?xxx=10")
t.Log(result.Body)
assert.Equal(t, http.StatusBadRequest, result.Code)
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
app, router, conf := NewApiTest()
t.Log(router)
t.Log(ctx)
t.Log(conf)
result := PerformRequest(app, "GET", "/api/v1/albums?xxx=10")
t.Log(result.Body)
@ -47,9 +47,9 @@ func TestLikeAlbum(t *testing.T) {
func TestDislikeAlbum(t *testing.T) {
t.Run("dislike not existing album", func(t *testing.T) {
app, router, ctx := NewApiTest()
app, router, conf := NewApiTest()
LikeAlbum(router, ctx)
LikeAlbum(router, conf)
result := PerformRequest(app, "DELETE", "/api/v1/albums/5678/like")
t.Log(result.Body)

16
internal/api/api.go Normal file
View file

@ -0,0 +1,16 @@
/*
This package contains the PhotoPrism REST api.
Additional information can be found in our Developer Guide:
https://github.com/photoprism/photoprism/wiki
*/
package api
import "github.com/sirupsen/logrus"
var log *logrus.Logger
func init() {
log = logrus.StandardLogger()
}

View file

@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
)
@ -21,6 +20,11 @@ type BatchParams struct {
// POST /api/v1/batch/photos/delete
func BatchPhotosDelete(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
var params BatchParams
@ -52,6 +56,11 @@ func BatchPhotosDelete(router *gin.RouterGroup, conf *config.Config) {
// POST /api/v1/batch/photos/private
func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/photos/private", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
var params BatchParams
@ -82,6 +91,11 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
// POST /api/v1/batch/photos/story
func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/photos/story", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
var params BatchParams
@ -103,7 +117,6 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
db.Model(models.Photo{}).Where("id IN (?)", params.Ids).Updates(map[string]interface{}{
"photo_story": gorm.Expr("IF (`photo_story`, 0, 1)"),
"photo_private": "0",
})
elapsed := time.Since(start)

View file

@ -5,7 +5,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"

View file

@ -7,5 +7,6 @@ import (
)
var (
ErrReadOnly = gin.H{"error": util.UcFirst(config.ErrReadOnly.Error())}
ErrReadOnly = gin.H{"code": 403, "error": util.UcFirst(config.ErrReadOnly.Error())}
ErrUnauthorized = gin.H{"code": 401, "error": util.UcFirst(config.ErrUnauthorized.Error())}
)

View file

@ -3,10 +3,11 @@ package api
import (
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/photoprism/photoprism/internal/config"
log "github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/event"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
@ -34,6 +35,11 @@ func Import(router *gin.RouterGroup, conf *config.Config) {
return
}
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
path := conf.ImportPath()
@ -42,14 +48,19 @@ func Import(router *gin.RouterGroup, conf *config.Config) {
path = path + subPath
}
log.Infof("importing photos from %s", path)
event.Info(fmt.Sprintf("importing photos from \"%s\"", filepath.Base(path)))
initImporter(conf)
importer.ImportPhotosFromDirectory(path)
elapsed := time.Since(start)
elapsed := int(time.Since(start).Seconds())
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("completed import in %s", elapsed)})
event.Success(fmt.Sprintf("import completed in %d s", elapsed))
event.Publish("import.completed", event.Data{"path": path, "seconds": elapsed})
event.Publish("index.completed", event.Data{"path": path, "seconds": elapsed})
event.Publish("config.updated", event.Data(conf.ClientConfig()))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("import completed in %d s", elapsed)})
})
}

View file

@ -3,12 +3,12 @@ package api
import (
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/photoprism/photoprism/internal/config"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
)
@ -27,17 +27,26 @@ func initIndexer(conf *config.Config) {
// POST /api/v1/index
func Index(router *gin.RouterGroup, conf *config.Config) {
router.POST("/index", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
path := conf.OriginalsPath()
log.Infof("indexing photos in %s", path)
event.Info(fmt.Sprintf("indexing photos in \"%s\"", filepath.Base(path)))
initIndexer(conf)
indexer.IndexAll()
elapsed := time.Since(start)
elapsed := int(time.Since(start).Seconds())
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("indexing completed in %s", elapsed)})
event.Success(fmt.Sprintf("indexing completed in %d s", elapsed))
event.Publish("index.completed", event.Data{"path": path, "seconds": elapsed})
event.Publish("config.updated", event.Data(conf.ClientConfig()))
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("indexing completed in %d s", elapsed)})
})
}

View file

@ -31,8 +31,8 @@ func GetLabels(router *gin.RouterGroup, conf *config.Config) {
return
}
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.Header("X-Result-Count", strconv.Itoa(form.Count))
c.Header("X-Result-Offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, result)
})
@ -44,6 +44,11 @@ func GetLabels(router *gin.RouterGroup, conf *config.Config) {
// slug: string Label slug name
func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
router.POST("/labels/:slug/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
label, err := search.FindLabelBySlug(c.Param("slug"))
@ -66,6 +71,11 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
// slug: string Label slug name
func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/labels/:slug/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
label, err := search.FindLabelBySlug(c.Param("slug"))

View file

@ -6,7 +6,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
@ -46,8 +45,8 @@ func GetPhotos(router *gin.RouterGroup, conf *config.Config) {
return
}
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.Header("X-Result-Count", strconv.Itoa(form.Count))
c.Header("X-Result-Offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, result)
})
@ -59,6 +58,11 @@ func GetPhotos(router *gin.RouterGroup, conf *config.Config) {
// id: int Photo ID as returned by the API
func LikePhoto(router *gin.RouterGroup, conf *config.Config) {
router.POST("/photos/:id/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
photoID, err := strconv.ParseUint(c.Param("id"), 10, 64)
@ -88,6 +92,11 @@ func LikePhoto(router *gin.RouterGroup, conf *config.Config) {
// id: int Photo ID as returned by the API
func DislikePhoto(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/photos/:id/like", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
search := photoprism.NewSearch(conf.OriginalsPath(), conf.Db())
id, err := strconv.ParseUint(c.Param("id"), 10, 64)

75
internal/api/session.go Normal file
View file

@ -0,0 +1,75 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
)
type CreateSessionParams struct {
Email string `json:"email"`
Password string `json:"password"`
}
// POST /api/v1/session
func CreateSession(router *gin.RouterGroup, conf *config.Config) {
router.POST("/session", func(c *gin.Context) {
var params CreateSessionParams
if err := c.BindJSON(&params); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
if params.Password != conf.AdminPassword() {
c.AbortWithStatusJSON(400, gin.H{"error": "Invalid password"})
return
}
token, _ := util.RandomToken(16)
c.Header("X-Session-Token", token)
gc := conf.Cache()
gc.Set(token, 1, cache.DefaultExpiration);
s := gin.H{"token": token, "user": gin.H{"ID": 1, "FirstName": "Admin", "LastName": "", "Role": "admin", "Email": "photoprism@localhost"}}
c.JSON(http.StatusOK, s)
})
}
// DELETE /api/v1/session/
func DeleteSession(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/session/:token", func(c *gin.Context) {
token := c.Param("token")
gc := conf.Cache()
gc.Delete(token)
c.JSON(http.StatusOK, gin.H{"status": "ok", "token": token})
})
}
// Returns true, if user doesn't have a valid session token
func Unauthorized(c *gin.Context, conf *config.Config) bool {
// Always return false if site is public
if conf.Public() {
return false
}
// Get session token from HTTP header
token := c.GetHeader("X-Session-Token")
log.Debugf("X-Session-Token: %s", token)
// Check if session token is valid
gc := conf.Cache()
_, found := gc.Get(token)
return !found
}

50
internal/api/settings.go Normal file
View file

@ -0,0 +1,50 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/util"
)
// GET /api/v1/settings
func GetSettings(router *gin.RouterGroup, conf *config.Config) {
router.GET("/settings", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
s := conf.Settings()
c.JSON(http.StatusOK, s)
})
}
// POST /api/v1/settings
func SaveSettings(router *gin.RouterGroup, conf *config.Config) {
router.POST("/settings", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
s := conf.Settings()
if err := c.BindJSON(s); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
if err := s.WriteValuesToFile(conf.SettingsFile()); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
event.Publish("config.updated", event.Data(conf.ClientConfig()))
c.JSON(http.StatusOK, gin.H{"message": "saved"})
})
}

View file

@ -6,7 +6,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"

View file

@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
)
@ -22,6 +21,11 @@ func Upload(router *gin.RouterGroup, conf *config.Config) {
return
}
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
start := time.Now()
subPath := c.Param("path")

79
internal/api/websocket.go Normal file
View file

@ -0,0 +1,79 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
)
var wsConnection = websocket.Upgrader{}
var wsTimeout = 60 * time.Second
func wsReader(ws *websocket.Conn) {
defer ws.Close()
ws.SetReadLimit(512)
ws.SetReadDeadline(time.Now().Add(wsTimeout))
ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for {
_, m, err := ws.ReadMessage()
if err != nil {
break
}
log.Infof("received: %s", m)
}
}
func wsWriter(ws *websocket.Conn, conf *config.Config) {
pingTicker := time.NewTicker(10 * time.Second)
s := event.Subscribe("notify.*", "index.*", "upload.*", "import.*", "config.*")
defer func() {
pingTicker.Stop()
event.Unsubscribe(s)
ws.Close()
}()
for {
select {
case <-pingTicker.C:
ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
case msg := <-s.Receiver:
ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := ws.WriteJSON(gin.H{"event": msg.Name, "data": msg.Fields}); err != nil {
log.Errorf("write json: %s", err)
return
}
}
}
}
// GET /api/v1/ws
func Websocket(router *gin.RouterGroup, conf *config.Config) {
router.GET("/ws", func(c *gin.Context) {
w := c.Writer
r := c.Request
ws, err := wsConnection.Upgrade(w, r, nil)
if err != nil {
log.Errorf("upgrade error: %s", err)
return
}
defer ws.Close()
log.Infof("websocket connected: %s", c.Request.RemoteAddr)
go wsWriter(ws, conf)
wsReader(ws)
})
}

View file

@ -23,6 +23,8 @@ func configAction(ctx *cli.Context) error {
fmt.Printf("copyright %s\n", conf.Copyright())
fmt.Printf("debug %t\n", conf.Debug())
fmt.Printf("read-only %t\n", conf.ReadOnly())
fmt.Printf("public %t\n", conf.Public())
fmt.Printf("admin-password %s\n", conf.AdminPassword())
fmt.Printf("log-level %s\n", conf.LogLevel())
fmt.Printf("log-filename %s\n", conf.LogFilename())
fmt.Printf("pid-filename %s\n", conf.PIDFilename())

View file

@ -17,10 +17,12 @@ import (
log "github.com/sirupsen/logrus"
tensorflow "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/urfave/cli"
gc "github.com/patrickmn/go-cache"
)
type Config struct {
db *gorm.DB
cache *gc.Cache
config *Params
}
@ -201,11 +203,25 @@ func (c *Config) Debug() bool {
return c.config.Debug
}
// Public returns true if app requires no authentication.
func (c *Config) Public() bool {
return c.config.Public
}
// ReadOnly returns true if photo directories are write protected.
func (c *Config) ReadOnly() bool {
return c.config.ReadOnly
}
// AdminPassword returns the admin password.
func (c *Config) AdminPassword() string {
if c.config.AdminPassword == "" {
return "photoprism"
}
return c.config.AdminPassword
}
// LogLevel returns the logrus log level.
func (c *Config) LogLevel() log.Level {
if c.Debug() {
@ -224,6 +240,11 @@ func (c *Config) ConfigFile() string {
return c.config.ConfigFile
}
// SettingsFile returns the user settings file name.
func (c *Config) SettingsFile() string {
return c.ConfigPath() + "/settings.yml"
}
// ConfigPath returns the config path.
func (c *Config) ConfigPath() string {
if c.config.ConfigPath == "" {
@ -431,6 +452,15 @@ func (c *Config) HttpStaticBuildPath() string {
return c.HttpStaticPath() + "/build"
}
// Cache returns the in-memory cache.
func (c *Config) Cache() *gc.Cache {
if c.cache == nil {
c.cache = gc.New(336*time.Hour, 30*time.Minute)
}
return c.cache
}
// Db returns the db connection.
func (c *Config) Db() *gorm.DB {
if c.db == nil {
@ -501,11 +531,13 @@ func (c *Config) ClientConfig() ClientConfig {
"copyright": c.Copyright(),
"debug": c.Debug(),
"readonly": c.ReadOnly(),
"public": c.Public(),
"cameras": cameras,
"countries": countries,
"thumbnails": Thumbnails,
"jsHash": jsHash,
"cssHash": cssHash,
"settings": c.Settings(),
}
return result
@ -516,6 +548,7 @@ func (c *Config) Init(ctx context.Context) error {
return c.connectToDatabase(ctx)
}
// Shutdown closes open database connections.
func (c *Config) Shutdown() {
if err := c.CloseDb(); err != nil {
log.Errorf("could not close database connection: %s", err)
@ -523,3 +556,15 @@ func (c *Config) Shutdown() {
log.Info("closed database connection")
}
}
// Settings returns the current user settings.
func (c *Config) Settings() *Settings {
s := NewSettings()
p := c.SettingsFile()
if err := s.SetValuesFromFile(p); err != nil {
log.Error(err)
}
return s
}

View file

@ -6,4 +6,5 @@ import (
var (
ErrReadOnly = errors.New("not available in read-only mode")
ErrUnauthorized = errors.New("please log in and try again")
)

View file

@ -16,6 +16,17 @@ var GlobalFlags = []cli.Flag{
Usage: "run in read-only mode",
EnvVar: "PHOTOPRISM_READ_ONLY",
},
cli.BoolFlag{
Name: "public",
Usage: "no authentication required",
EnvVar: "PHOTOPRISM_PUBLIC",
},
cli.StringFlag{
Name: "admin-password",
Usage: "admin password",
Value: "photoprism",
EnvVar: "PHOTOPRISM_ADMIN_PASSWORD",
},
cli.StringFlag{
Name: "log-level, l",
Usage: "trace, debug, info, warning, error, fatal or panic",
@ -132,11 +143,6 @@ var GlobalFlags = []cli.Flag{
Usage: "debug, release or test",
EnvVar: "PHOTOPRISM_HTTP_MODE",
},
cli.StringFlag{
Name: "http-password",
Usage: "HTTP server password (optional)",
EnvVar: "PHOTOPRISM_HTTP_PASSWORD",
},
cli.IntFlag{
Name: "sql-port, s",
Usage: "built-in SQL server port",

View file

@ -33,6 +33,8 @@ type Params struct {
Copyright string
Debug bool `yaml:"debug" flag:"debug"`
ReadOnly bool `yaml:"read-only" flag:"read-only"`
Public bool `yaml:"public" flag:"public"`
AdminPassword string `yaml:"admin-password" flag:"admin-password"`
LogLevel string `yaml:"log-level" flag:"log-level"`
ConfigFile string
ConfigPath string `yaml:"config-path" flag:"config-path"`

View file

@ -0,0 +1,50 @@
package config
import (
"fmt"
"io/ioutil"
"os"
"github.com/photoprism/photoprism/internal/util"
"gopkg.in/yaml.v2"
)
type Settings struct {
Theme string `json:"theme" yaml:"theme" flag:"theme"`
Language string `json:"language" yaml:"language" flag:"language"`
}
func NewSettings() *Settings {
return &Settings{}
}
// SetValuesFromFile uses a yaml config file to initiate the configuration entity.
func (s *Settings) SetValuesFromFile(fileName string) error {
if !util.Exists(fileName) {
return fmt.Errorf("settings file not found: \"%s\"", fileName)
}
yamlConfig, err := ioutil.ReadFile(fileName)
if err != nil {
return err
}
return yaml.Unmarshal(yamlConfig, s)
}
// WriteValuesToFile uses a yaml config file to initiate the configuration entity.
func (s *Settings) WriteValuesToFile(fileName string) error {
if !util.Exists(fileName) {
return fmt.Errorf("settings file not found: \"%s\"", fileName)
}
data, err := yaml.Marshal(s)
if err != nil {
return err
}
return ioutil.WriteFile(fileName, data, os.ModePerm)
}

View file

@ -33,6 +33,8 @@ func NewTestParams() *Params {
testDataPath := testDataPath(assetsPath)
c := &Params{
Public: true,
ReadOnly: false,
DarktableBin: "/usr/bin/darktable-cli",
AssetsPath: assetsPath,
CachePath: testDataPath + "/cache",
@ -40,7 +42,7 @@ func NewTestParams() *Params {
ImportPath: testDataPath + "/import",
ExportPath: testDataPath + "/export",
DatabaseDriver: "mysql",
DatabaseDsn: "photoprism:photoprism@tcp(photoprism-mysql:4001)/photoprism?parseTime=true",
DatabaseDsn: "photoprism:photoprism@tcp(photoprism-db:4001)/photoprism?parseTime=true",
}
return c
@ -59,7 +61,7 @@ func NewTestParamsError() *Params {
ImportPath: testDataPath + "/import",
ExportPath: testDataPath + "/export",
DatabaseDriver: "mysql",
DatabaseDsn: "photoprism:photoprism@tcp(photoprism-mysql:4001)/photoprism?parseTime=true",
DatabaseDsn: "photoprism:photoprism@tcp(photoprism-db:4001)/photoprism?parseTime=true",
}
return c

61
internal/event/hub.go Normal file
View file

@ -0,0 +1,61 @@
package event
import (
"github.com/leandro-lugaresi/hub"
"github.com/sirupsen/logrus"
)
type Hub = hub.Hub
type Data = hub.Fields
type Message = hub.Message
var log *logrus.Logger
var channelCap = 10
var sharedHub = NewHub()
func init() {
log = logrus.StandardLogger()
}
func NewHub () *Hub {
return hub.New()
}
func SharedHub() *Hub {
return sharedHub
}
func Error(msg string) {
log.Error(msg)
Publish("notify.error", Data{"msg": msg})
}
func Success(msg string) {
log.Info(msg)
Publish("notify.success", Data{"msg": msg})
}
func Info(msg string) {
log.Info(msg)
Publish("notify.info", Data{"msg": msg})
}
func Warning(msg string) {
log.Warn(msg)
Publish("notify.warning", Data{"msg": msg})
}
func Publish (event string, data Data) {
log.Infof("publish %s: %v", event, data)
SharedHub().Publish(Message{
Name: event,
Fields: data,
})
}
func Subscribe(topics ...string) hub.Subscription {
return SharedHub().Subscribe(channelCap, topics...)
}
func Unsubscribe(s hub.Subscription) {
SharedHub().Unsubscribe(s)
}

View file

@ -0,0 +1,31 @@
package event
import (
"testing"
"github.com/leandro-lugaresi/hub"
"github.com/stretchr/testify/assert"
)
func TestSharedHub(t *testing.T) {
h := SharedHub()
assert.IsType(t, &hub.Hub{}, h)
}
func TestPublishSubscribe(t *testing.T) {
s := Subscribe("foo.bar")
assert.IsType(t, hub.Subscription{}, s)
Publish("foo.bar", Data{"id": 13})
msg := <-s.Receiver
t.Logf("receive msg with topic %s: %v\n", msg.Name, msg.Fields)
assert.Equal(t, "foo.bar", msg.Name)
assert.Equal(t, Data{"id": 13}, msg.Fields)
Unsubscribe(s)
}

View file

@ -27,7 +27,7 @@ type Photo struct {
PhotoAltitude int
PhotoFocalLength int
PhotoIso int
PhotoAperture float64
PhotoFNumber float64
PhotoExposure string
PhotoViews uint
Camera *Camera

View file

@ -21,11 +21,14 @@ type Exif struct {
Artist string
CameraMake string
CameraModel string
Description string
LensMake string
LensModel string
Flash bool
FocalLength int
Exposure string
Aperture float64
FNumber float64
Iso int
Lat float64
Long float64
@ -148,6 +151,17 @@ func (m *MediaFile) Exif() (result *Exif, err error) {
m.exifData.Exposure = value
}
if value, ok := tags["FNumber"]; ok {
values := strings.Split(value, "/")
if len(values) == 2 && values[1] != "0" && values[1] != "" {
number, _ := strconv.ParseFloat(values[0], 64)
denom, _ := strconv.ParseFloat(values[1], 64)
m.exifData.FNumber = math.Round((number/denom)*1000) / 1000
}
}
if value, ok := tags["ApertureValue"]; ok {
values := strings.Split(value, "/")
@ -246,6 +260,16 @@ func (m *MediaFile) Exif() (result *Exif, err error) {
}
}
if value, ok := tags["Flash"]; ok {
if i, err := strconv.Atoi(value); err == nil && i&1 == 1 {
m.exifData.Flash = true
}
}
if value, ok := tags["ImageDescription"]; ok {
m.exifData.Description = strings.Replace(value, "\"", "", -1)
}
m.exifData.All = tags
return m.exifData, nil

View file

@ -11,20 +11,75 @@ import (
func TestMediaFile_Exif_JPEG(t *testing.T) {
conf := config.TestConfig()
img, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
t.Run("elephants.jpg", func(t *testing.T) {
img, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
assert.Nil(t, err)
assert.Nil(t, err)
info, err := img.Exif()
info, err := img.Exif()
assert.Empty(t, err)
assert.Empty(t, err)
assert.IsType(t, &Exif{}, info)
assert.IsType(t, &Exif{}, info)
assert.Equal(t, "Canon EOS 6D", info.CameraModel)
assert.Equal(t, "Africa/Johannesburg", info.TimeZone)
t.Logf("UTC: %s", info.TakenAt.String())
t.Logf("Local: %s", info.TakenAtLocal.String())
assert.Equal(t, "", info.UUID)
assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", info.TakenAt.String())
assert.Equal(t, "2013-11-26 15:53:55 +0000 UTC", info.TakenAtLocal.String())
assert.Equal(t, 1, info.Orientation)
assert.Equal(t, "Canon EOS 6D", info.CameraModel)
assert.Equal(t, "Canon", info.CameraMake)
assert.Equal(t, "EF70-200mm f/4L IS USM", info.LensModel)
assert.Equal(t, "", info.LensMake)
assert.Equal(t, "Africa/Johannesburg", info.TimeZone)
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, 200, info.Iso)
assert.Equal(t, -33.45347, info.Lat)
assert.Equal(t, 25.764645, info.Long)
assert.Equal(t, 190, info.Altitude)
assert.Equal(t, 1365, info.Width)
assert.Equal(t, 0, info.Height)
assert.Equal(t, false, info.Flash)
assert.Equal(t, "", info.Description)
t.Logf("UTC: %s", info.TakenAt.String())
t.Logf("Local: %s", info.TakenAtLocal.String())
})
t.Run("fern_green.jpg", func(t *testing.T) {
img, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg")
assert.Nil(t, err)
info, err := img.Exif()
assert.Empty(t, err)
assert.IsType(t, &Exif{}, info)
assert.Equal(t, "", info.UUID)
assert.Equal(t, 1, info.Orientation)
assert.Equal(t, "Canon EOS 7D", info.CameraModel)
assert.Equal(t, "Canon", info.CameraMake)
assert.Equal(t, "EF100mm f/2.8L Macro IS USM", info.LensModel)
assert.Equal(t, "", info.LensMake)
assert.Equal(t, "", info.TimeZone)
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, 200, info.Iso)
assert.Equal(t, 0, info.Altitude)
assert.Equal(t, 2048, info.Width)
assert.Equal(t, 0, info.Height)
assert.Equal(t, true, info.Flash)
assert.Equal(t, "", info.Description)
t.Logf("UTC: %s", info.TakenAt.String())
t.Logf("Local: %s", info.TakenAtLocal.String())
})
}
func TestMediaFile_Exif_DNG(t *testing.T) {
@ -44,7 +99,25 @@ func TestMediaFile_Exif_DNG(t *testing.T) {
assert.IsType(t, &Exif{}, info)
assert.Equal(t, "", info.UUID)
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", info.TakenAt.String())
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", info.TakenAtLocal.String())
assert.Equal(t, 1, info.Orientation)
assert.Equal(t, "Canon EOS 6D", info.CameraModel)
assert.Equal(t, "Canon", info.CameraMake)
assert.Equal(t, "EF24-105mm f/4L IS USM", info.LensModel)
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, 1000, info.Iso)
assert.Equal(t, 0.0, info.Lat)
assert.Equal(t, 0.0, info.Long)
assert.Equal(t, 0, info.Altitude)
assert.Equal(t, 171, info.Width)
assert.Equal(t, 0, info.Height)
assert.Equal(t, false, info.Flash)
assert.Equal(t, "", info.Description)
}
func TestMediaFile_Exif_HEIF(t *testing.T) {
@ -76,7 +149,27 @@ func TestMediaFile_Exif_HEIF(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, "", jpegInfo.UUID)
assert.Equal(t, "2018-09-10 03:16:13 +0000 UTC", jpegInfo.TakenAt.String())
assert.Equal(t, "2018-09-10 12:16:13 +0000 UTC", jpegInfo.TakenAtLocal.String())
assert.Equal(t, 6, jpegInfo.Orientation)
assert.Equal(t, "iPhone 7", jpegInfo.CameraModel)
assert.Equal(t, "Apple", jpegInfo.CameraMake)
assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", jpegInfo.LensModel)
assert.Equal(t, "Apple", jpegInfo.LensMake)
assert.Equal(t, "Asia/Tokyo", jpegInfo.TimeZone)
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, 20, jpegInfo.Iso)
assert.Equal(t, 34.79745, jpegInfo.Lat)
assert.Equal(t, 134.76463333333334, jpegInfo.Long)
assert.Equal(t, 0, jpegInfo.Altitude)
assert.Equal(t, 0, jpegInfo.Width)
assert.Equal(t, 0, jpegInfo.Height)
assert.Equal(t, false, jpegInfo.Flash)
assert.Equal(t, "", jpegInfo.Description)
if err := os.Remove(conf.ExamplesPath() + "/iphone_7.jpg"); err != nil {
t.Error(err)

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/util"
@ -78,11 +79,16 @@ func (i *Importer) ImportPhotosFromDirectory(importPath string) {
relatedFiles, mainFile, err := mediaFile.RelatedFiles()
if err != nil {
log.Errorf("could not import \"%s\": %s", mediaFile.RelativeFilename(importPath), err.Error())
event.Error(fmt.Sprintf("could not import \"%s\": %s", mediaFile.RelativeFilename(importPath), err.Error()))
return nil
}
event.Publish("import.file", event.Data{
"fileName": mainFile.Filename(),
"baseName": filepath.Base(mainFile.Filename()),
})
for _, relatedMediaFile := range relatedFiles {
relativeFilename := relatedMediaFile.RelativeFilename(importPath)

View file

@ -10,6 +10,7 @@ import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/util"
)
@ -113,6 +114,12 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
fileName := mediaFile.RelativeFilename(i.originalsPath())
fileHash := mediaFile.Hash()
event.Publish("index.file", event.Data{
"fileHash": fileHash,
"fileName": fileName,
"baseName": filepath.Base(fileName),
})
exifData, err := mediaFile.Exif()
if err != nil {
@ -156,11 +163,11 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
}
}
// Set Camera, Lens, Focal Length and Aperture
// Set Camera, Lens, Focal Length and F Number
photo.Camera = models.NewCamera(mediaFile.CameraModel(), mediaFile.CameraMake()).FirstOrCreate(i.db)
photo.Lens = models.NewLens(mediaFile.LensModel(), mediaFile.LensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.FocalLength()
photo.PhotoAperture = mediaFile.Aperture()
photo.PhotoFNumber = mediaFile.FNumber()
photo.PhotoIso = mediaFile.Iso()
photo.PhotoExposure = mediaFile.Exposure()
}

View file

@ -156,14 +156,14 @@ func (m *MediaFile) FocalLength() int {
return result
}
// Aperture returns the aperture with which the media file was created.
func (m *MediaFile) Aperture() float64 {
// FNumber returns the F number with which the media file was created.
func (m *MediaFile) FNumber() float64 {
info, err := m.Exif()
var result float64
if err == nil {
result = info.Aperture
result = info.FNumber
}
return result

View file

@ -136,20 +136,20 @@ func TestMediaFile_FocalLength(t *testing.T) {
})
}
func TestMediaFile_Aperture(t *testing.T) {
func TestMediaFile_FNumber(t *testing.T) {
t.Run("/cat_brown.jpg", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg")
assert.Nil(t, err)
assert.Equal(t, 2.275, mediaFile.Aperture())
assert.Equal(t, 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, 6.644, mediaFile.Aperture())
assert.Equal(t, 10.0, mediaFile.FNumber())
})
}

View file

@ -171,11 +171,11 @@ func (s *Search) Photos(form forms.PhotoSearchForm) (results []PhotoSearchResult
}
if form.Fmin > 0 {
q = q.Where("photos.photo_aperture >= ?", form.Fmin)
q = q.Where("photos.photo_f_number >= ?", form.Fmin)
}
if form.Fmax > 0 {
q = q.Where("photos.photo_aperture <= ?", form.Fmax)
q = q.Where("photos.photo_f_number <= ?", form.Fmax)
}
if form.Dist == 0 {

View file

@ -20,12 +20,17 @@ type PhotoSearchResult struct {
PhotoKeywords string
PhotoColors string
PhotoColor string
PhotoLat float64
PhotoLong float64
PhotoFavorite bool
PhotoPrivate bool
PhotoSensitive bool
PhotoStory bool
PhotoLat float64
PhotoLong float64
PhotoAltitude int
PhotoFocalLength int
PhotoIso int
PhotoFNumber float64
PhotoExposure string
// Camera
CameraID uint

View file

@ -15,9 +15,18 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// Static assets like js and css files
router.Static("/static", conf.HttpStaticPath())
// socket.io
/* s := router.Group("/socket.io")
{
api.Socket(s, conf)
} */
// JSON-REST API Version 1
v1 := router.Group("/api/v1")
{
api.CreateSession(v1, conf)
api.DeleteSession(v1, conf)
api.GetThumbnail(v1, conf)
api.GetDownload(v1, conf)
@ -43,6 +52,11 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DislikeAlbum(v1, conf)
api.AlbumThumbnail(v1, conf)
api.CreateAlbum(v1, conf)
api.GetSettings(v1, conf)
api.SaveSettings(v1, conf)
api.Websocket(v1, conf)
}
// Default HTML page (client-side routing implemented via Vue.js)

14
internal/util/token.go Normal file
View file

@ -0,0 +1,14 @@
package util
import (
"crypto/rand"
"fmt"
)
func RandomToken(size int) (string, error) {
b := make([]byte, size)
_, err := rand.Read(b)
return fmt.Sprintf("%x", b), err
}