Add simple file browser to Library #260

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer
2020-05-24 22:16:06 +02:00
parent 8d712f4d7e
commit 4421e7d203
52 changed files with 755 additions and 288 deletions

View File

@@ -11,6 +11,8 @@ features:
review: true
upload: true
import: true
files: true
moments: true
labels: true
places: true
download: true

View File

@@ -57,6 +57,9 @@ class Config {
case "videos":
this.values.count.videos += data.count;
break;
case "files":
this.values.count.files += data.count;
break;
case "folders":
this.values.count.folders += data.count;
break;

View File

@@ -4,7 +4,7 @@ const Notify = {
info: function (message) {
Event.publish("notify.info", {msg: message});
},
warning: function (message) {
warn: function (message) {
Event.publish("notify.warning", {msg: message});
},
error: function (message) {
@@ -38,7 +38,7 @@ const Notify = {
}
},
wait: function () {
this.warning("Busy, please wait...");
this.warn("Busy, please wait...");
},
};

View File

@@ -25,7 +25,7 @@
<v-toolbar flat>
<v-list class="navigation-home">
<v-list-tile class="p-navigation-logo">
<v-list-tile-avatar class="p-pointer" @click.stop.prevent="openDocs">
<v-list-tile-avatar class="clickable" @click.stop.prevent="openDocs">
<img src="/static/img/logo.png" alt="Logo">
</v-list-tile-avatar>
<v-list-tile-content>
@@ -142,7 +142,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-group v-if="!mini" prepend-icon="folder" no-action>
<v-list-group v-if="!mini" prepend-icon="folder" no-action :append-icon="albumExpandIcon">
<v-list-tile slot="activator" to="/albums" @click.stop="">
<v-list-tile-content>
<v-list-tile-title>
@@ -152,15 +152,6 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/folders" @click="" class="p-navigation-folders" v-show="$config.feature('folders')">
<v-list-tile-content>
<v-list-tile-title>
<translate key="Folders">Folders</translate>
<span v-show="config.count.folders > 0" class="p-navigation-count">{{ config.count.folders }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-for="(album, index) in config.albums"
:key="index"
:to="{ name: 'album', params: { uid: album.UID, slug: album.Slug } }">
@@ -271,18 +262,37 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/library" @click="" class="p-navigation-library">
<v-list-tile v-if="mini" to="/library" @click="" class="p-navigation-library">
<v-list-tile-action>
<v-icon>camera_roll</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
<translate key="Originals">Originals</translate>
<translate key="Library">Library</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-group v-if="!mini" prepend-icon="camera_roll" no-action>
<v-list-tile slot="activator" to="/library" @click.stop="" class="p-navigation-library">
<v-list-tile-content>
<v-list-tile-title>
<translate key="Library">Library</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/files" @click="" class="p-navigation-files" v-show="$config.feature('files')">
<v-list-tile-content>
<v-list-tile-title>
<translate key="Files">Files</translate>
<span v-show="config.count.files > 0" class="p-navigation-count">{{ config.count.files }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list-group>
<v-list-tile to="/settings" @click="" class="p-navigation-settings" v-show="!config.disableSettings">
<v-list-tile-action>
<v-icon>settings</v-icon>

View File

@@ -31,8 +31,7 @@
<v-img :src="photo.thumbnailUrl('tile_500')"
aspect-ratio="1"
v-bind:class="{ selected: $clipboard.has(photo) }"
style="cursor: pointer;"
class="accent lighten-2"
class="accent lighten-2 clickable"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
@@ -140,8 +139,8 @@
<br/>
<button @click.exact="downloadFile(index)"
title="Name">
<v-icon size="14">save</v-icon>
{{ photo.FileName }}
<v-icon size="14">insert_drive_file</v-icon>
{{ photo.baseName() }}
</button>
</template>
<template v-if="showLocation && photo.LocationID">

View File

@@ -11,7 +11,7 @@
>
<template slot="items" slot-scope="props">
<td style="user-select: none;">
<v-img class="accent lighten-2" style="cursor: pointer" aspect-ratio="1"
<v-img class="accent lighten-2 clickable" aspect-ratio="1"
:src="props.item.thumbnailUrl('tile_50')"
@mousedown="onMouseDown($event, props.index)"
@contextmenu="onContextMenu($event, props.index)"
@@ -39,7 +39,7 @@
</v-btn>
</v-img>
</td>
<td class="p-photo-desc p-pointer" @click.exact="editPhoto(props.index)" style="user-select: none;">
<td class="p-photo-desc clickable" @click.exact="editPhoto(props.index)" style="user-select: none;">
{{ props.item.Title }}
</td>
<td class="p-photo-desc hidden-xs-only" :title="props.item.getDateString()">

View File

@@ -30,8 +30,7 @@
:title="photo.Title">
<v-img :src="photo.thumbnailUrl('tile_224')"
aspect-ratio="1"
class="accent lighten-2"
style="cursor: pointer"
class="accent lighten-2 clickable"
@mousedown="onMouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>

View File

@@ -7,7 +7,7 @@
@import url("viewer.css");
@import url("photos.css");
@import url("labels.css");
@import url("folders.css");
@import url("files.css");
html,
body {
@@ -79,6 +79,11 @@ main {
color: #333333;
}
#photoprism .v-toolbar a {
text-decoration: none;
color: #333333;
}
#photoprism main .p-inline-edit a,
#photoprism main .p-inline-edit a span {
cursor: text;
@@ -92,7 +97,8 @@ main {
top: -8px;
}
#photoprism .p-pointer {
#photoprism .p-pointer,
#photoprism .clickable {
cursor: pointer;
}

View File

@@ -0,0 +1,5 @@
#photoprism .p-folders-cards .p-folder-like,
#photoprism .p-files-cards .p-files-like {
left: 4px;
bottom: 4px;
}

View File

@@ -1,4 +0,0 @@
#photoprism .p-folders-cards .p-folder-like {
left: 4px;
bottom: 4px;
}

View File

@@ -67,7 +67,8 @@
#photoprism .p-albums-cards .p-album-select,
#photoprism .p-labels-cards .p-label-select,
#photoprism .p-folders-cards .p-folder-select{
#photoprism .p-folders-cards .p-folder-select,
#photoprism .p-files-cards .p-file-select {
right: 4px;
bottom: 4px;
}

View File

@@ -14,8 +14,7 @@
:title="model.Title">
<v-img :src="model.thumbnailUrl('tile_500')"
aspect-ratio="1"
class="accent lighten-2 elevation-0"
style="cursor: pointer"
class="accent lighten-2 elevation-0 clickable"
@click.exact="openPhoto()"
v-touch="{left, right}"
>

197
frontend/src/model/file.js Normal file
View File

@@ -0,0 +1,197 @@
import RestModel from "model/rest";
import Api from "common/api";
import {DateTime} from "luxon";
import Util from "common/util";
export class File extends RestModel {
getDefaults() {
return {
UID: "",
PhotoUID: "",
Root: "",
Name: "",
OriginalName: "",
Hash: "",
Modified: "",
Size: 0,
Codec: "",
Type: "",
Mime: "",
Primary: false,
Sidecar: false,
Missing: false,
Duplicate: false,
Portrait: false,
Video: false,
Duration: 0,
Width: 0,
Height: 0,
Orientation: 0,
AspectRatio: 1.0,
MainColor: "",
Colors: "",
Luminance: "",
Diff: 0,
Chroma: 0,
Notes: "",
Error: "",
Links: [],
CreatedAt: "",
CreatedIn: 0,
UpdatedAt: "",
UpdatedIn: 0,
DeletedAt: "",
};
}
baseName(truncate) {
let result = this.Name;
const slash = result.lastIndexOf("/")
if (slash >= 0) {
result = this.Name.substring(slash + 1)
}
if(truncate) {
result = Util.truncate(result, truncate, "...")
}
return result
}
isFile() {
return true;
}
getEntityName() {
return this.Root + "/" + this.Name;
}
getId() {
return this.UID;
}
thumbnailUrl(type) {
return "/api/v1/thumbnails/" + this.Hash + "/" + type;
}
getDownloadUrl() {
return "/api/v1/download/" + this.Hash;
}
thumbnailSrcset() {
const result = [];
result.push(this.thumbnailUrl("fit_720") + " 720w");
result.push(this.thumbnailUrl("fit_1280") + " 1280w");
result.push(this.thumbnailUrl("fit_1920") + " 1920w");
result.push(this.thumbnailUrl("fit_2560") + " 2560w");
result.push(this.thumbnailUrl("fit_3840") + " 3840w");
return result.join(", ");
}
calculateSize(width, height) {
if (width >= this.Width && height >= this.Height) { // Smaller
return {width: this.Width, height: this.Height};
}
const srcAspectRatio = this.Width / this.Height;
const maxAspectRatio = width / height;
let newW, newH;
if (srcAspectRatio > maxAspectRatio) {
newW = width;
newH = Math.round(newW / srcAspectRatio);
} else {
newH = height;
newW = Math.round(newH * srcAspectRatio);
}
return {width: newW, height: newH};
}
thumbnailSizes() {
const result = [];
result.push("(min-width: 2560px) 3840px");
result.push("(min-width: 1920px) 2560px");
result.push("(min-width: 1280px) 1920px");
result.push("(min-width: 720px) 1280px");
result.push("720px");
return result.join(", ");
}
getDateString() {
return DateTime.fromISO(this.CreatedAt).toLocaleString(DateTime.DATETIME_MED);
}
getInfo() {
let info = [];
if (this.Type) {
info.push(this.Type.toUpperCase());
}
if (this.Duration > 0) {
info.push(Util.duration(this.Duration));
}
this.addSizeInfo(info);
return info.join(", ");
}
addSizeInfo(info) {
if (this.Width && this.Height) {
info.push(this.Width + " × " + this.Height);
}
if (this.Size > 102400) {
const size = Number.parseFloat(this.Size) / 1048576;
info.push(size.toFixed(1) + " MB");
} else if (this.Size) {
const size = Number.parseFloat(this.Size) / 1024;
info.push(size.toFixed(1) + " KB");
}
}
toggleLike() {
this.Favorite = !this.Favorite;
if (this.Favorite) {
return Api.post(this.getPhotoResource() + "/like");
} else {
return Api.delete(this.getPhotoResource() + "/like");
}
}
getPhotoResource() {
return "photos/" + this.PhotoUID;
}
like() {
this.Favorite = true;
return Api.post(this.getPhotoResource() + "/like");
}
unlike() {
this.Favorite = false;
return Api.delete(this.getPhotoResource() + "/like");
}
static getCollectionResource() {
return "files";
}
static getModelName() {
return "File";
}
}
export default File;

View File

@@ -1,13 +1,16 @@
import RestModel from "model/rest";
import Api from "common/api";
import {DateTime} from "luxon";
import File from "model/file";
import Util from "../common/util";
export const FolderRootOriginals = "originals";
export const FolderRootImport = "import";
export const RootOriginals = "originals";
export const RootImport = "import";
export class Folder extends RestModel {
getDefaults() {
return {
Folder: true,
Root: "",
Path: "",
UID: "",
@@ -25,6 +28,25 @@ export class Folder extends RestModel {
};
}
baseName(truncate) {
let result = this.Name;
const slash = result.lastIndexOf("/")
if (slash >= 0) {
result = this.Name.substring(slash + 1)
}
if(truncate) {
result = Util.truncate(result, truncate, "...")
}
return result
}
isFile() {
return false;
}
getEntityName() {
return this.Root + "/" + this.Path;
}
@@ -66,7 +88,11 @@ export class Folder extends RestModel {
}
static originals(path, params) {
return this.search(FolderRootOriginals + "/" + path, params);
if(!path) {
path = "/"
}
return this.search(RootOriginals + path, params);
}
static search(path, params) {
@@ -79,7 +105,11 @@ export class Folder extends RestModel {
}
return Api.get(this.getCollectionResource() + path, options).then((response) => {
let count = response.data.length;
let folders = response.data.folders;
let files = response.data.files ? response.data.files : [];
let count = folders.length + files.length;
let limit = 0;
let offset = 0;
@@ -102,8 +132,12 @@ export class Folder extends RestModel {
response.limit = limit;
response.offset = offset;
for (let i = 0; i < response.data.length; i++) {
response.models.push(new this(response.data[i]));
for (let i = 0; i < folders.length; i++) {
response.models.push(new this(folders[i]));
}
for (let i = 0; i < files.length; i++) {
response.models.push(new File(files[i]));
}
return Promise.resolve(response);

View File

@@ -74,6 +74,7 @@ export class Photo extends RestModel {
LocState: "",
LocCountry: "",
FileUID: "",
FileRoot: "",
FileName: "",
Hash: "",
Width: "",
@@ -81,6 +82,21 @@ export class Photo extends RestModel {
};
}
baseName(truncate) {
let result = this.FileName;
const slash = result.lastIndexOf("/")
if (slash >= 0) {
result = this.FileName.substring(slash + 1)
}
if(truncate) {
result = Util.truncate(result, truncate, "...")
}
return result
}
getEntityName() {
return this.Title;
}

View File

@@ -134,7 +134,7 @@ export class Thumb extends Model {
if (!p.Files) return;
p.Files.forEach((f) => {
if (f && f.FileType === "jpg") {
if (f && f.Type === "jpg") {
let thumb = this.fromFile(p, f);
if (thumb) {

View File

@@ -273,7 +273,7 @@
if (this.scrollDisabled) {
if (!this.results.length) {
this.$notify.warning(this.$gettext("No photos found"));
this.$notify.warn(this.$gettext("No photos found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One photo found"));
} else {

View File

@@ -358,7 +358,7 @@
if (this.scrollDisabled) {
if (!this.results.length) {
this.$notify.warning(this.$gettext("No albums found"));
this.$notify.warn(this.$gettext("No albums found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One album found"));
} else {

View File

@@ -1,34 +1,23 @@
<template>
<div class="p-page p-page-folders" v-infinite-scroll="loadMore" :infinite-scroll-disabled="scrollDisabled"
:infinite-scroll-distance="10" :infinite-scroll-listen-for-event="'scrollRefresh'">
<v-form ref="form" class="p-folders-search" lazy-validation @submit.prevent="updateQuery" dense>
<div class="p-page p-page-files">
<v-form ref="form" class="p-files-search" lazy-validation @submit.prevent="updateQuery" dense>
<v-toolbar flat color="secondary">
<v-text-field class="pt-3 pr-3"
single-line
:label="labels.search"
prepend-inner-icon="search"
browser-autocomplete="off"
clearable
color="secondary-dark"
@click:clear="clearQuery"
v-model="filter.q"
@keyup.enter.native="updateQuery"
id="search"
></v-text-field>
<v-toolbar-title>
<router-link to="/files">
<translate key="Originals">Originals</translate>
</router-link>
<router-link v-for="(item, index) in breadcrumbs" :key="index" :to="item.path">
<v-icon>navigate_next</v-icon>
{{item.name}}
</router-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click.stop="refresh">
<v-icon>refresh</v-icon>
</v-btn>
<v-btn v-if="!filter.all" icon @click.stop="showAll">
<v-icon>visibility</v-icon>
</v-btn>
<v-btn v-else icon @click.stop="showImportant">
<v-icon>visibility_off</v-icon>
</v-btn>
</v-toolbar>
</v-form>
@@ -37,37 +26,37 @@
</v-container>
<v-container fluid class="pa-0" v-else>
<p-folder-clipboard :refresh="refresh" :selection="selection"
:clear-selection="clearSelection"></p-folder-clipboard>
:clear-selection="clearSelection"></p-folder-clipboard>
<p-scroll-top></p-scroll-top>
<v-container grid-list-xs fluid class="pa-2 p-folders p-folders-cards">
<v-card v-if="results.length === 0" class="p-folders-empty secondary-light lighten-1 ma-1" flat>
<v-container grid-list-xs fluid class="pa-2 p-files p-files-cards">
<v-card v-if="results.length === 0" class="p-files-empty secondary-light lighten-1 ma-1" flat>
<v-card-title primary-title>
<div>
<h3 class="title mb-3">
<translate>No folders matched your search</translate>
<translate key="nofolders">No files matched your search</translate>
</h3>
<div>
<translate>Try again using a related or otherwise similar term.</translate>
<translate key="tryagain">Please re-index your originals if a file you expect is
missing.
</translate>
</div>
</div>
</v-card-title>
</v-card>
<v-layout row wrap class="p-folders-results">
<v-layout row wrap class="p-files-results">
<v-flex
v-for="(model, index) in results"
:key="index"
class="p-folder"
xs6 sm4 md3 lg2 d-flex
>
<v-hover>
<v-card tile class="accent lighten-3"
<v-card tile class="accent lighten-3 clickable"
slot-scope="{ hover }"
@contextmenu="onContextMenu($event, index)"
:dark="selection.includes(model.UID)"
:class="selection.includes(model.UID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'photos', query: {q: 'path:' + model.Path + '*' }}">
:class="selection.includes(model.UID) ? 'elevation-10 ma-0 darken-1 white--text' : 'elevation-0 ma-1 lighten-3'">
<v-img
:src="model.thumbnailUrl('tile_500')"
@mousedown="onMouseDown($event, index)"
@@ -88,7 +77,7 @@
<v-btn v-if="hover || selection.includes(model.UID)" :flat="!hover" :ripple="false"
icon large absolute
:class="selection.includes(model.UID) ? 'p-folder-select' : 'p-folder-select opacity-50'"
:class="selection.includes(model.UID) ? 'p-file-select' : 'p-file-select opacity-50'"
@click.stop.prevent="onSelect($event, index)">
<v-icon v-if="selection.includes(model.UID)" color="white">check_circle
</v-icon>
@@ -96,37 +85,34 @@
</v-btn>
</v-img>
<v-card-actions @click.stop.prevent="">
<v-edit-dialog
:return-value.sync="model.Title"
lazy
@save="onSave(model)"
class="p-inline-edit"
>
<span v-if="model.Title">
{{ model.Title | capitalize }}
</span>
<span v-else>
<v-icon>edit</v-icon>
</span>
<template v-slot:input>
<v-text-field
v-model="model.Title"
:rules="[titleRule]"
:label="labels.name"
color="secondary-dark"
single-line
autofocus
></v-text-field>
</template>
</v-edit-dialog>
<v-spacer></v-spacer>
<v-btn icon @click.stop.prevent="model.toggleLike()">
<v-icon v-if="model.Favorite" color="#FFD600">star
</v-icon>
<v-icon v-else color="accent lighten-2">star</v-icon>
</v-btn>
</v-card-actions>
<v-card-title primary-title class="pa-3 p-photo-desc" style="user-select: none;"
v-if="model.isFile()">
<div>
<h3 class="body-2 mb-2" :title="model.Name">
<button @click.exact="openFile(index)">
{{ model.baseName(19) }}
</button>
</h3>
<div class="caption" title="Info">
{{ model.getInfo() }}
</div>
</div>
</v-card-title>
<v-card-title primary-title class="pa-3 p-photo-desc" v-else>
<div>
<h3 class="body-2 mb-2" :title="model.Title">
<button @click.exact="openFile(index)">
{{ model.Title }}
</button>
</h3>
<div class="caption" title="Path" v-if="model.Title !== model.Path">
/{{ model.Path }}
</div>
<div class="caption" title="Path" v-else>
<translate key="Folder">Folder</translate>
</div>
</div>
</v-card-title>
</v-card>
</v-hover>
</v-flex>
@@ -137,12 +123,14 @@
</template>
<script>
import Folder from "model/folder";
import Event from "pubsub-js";
import RestModel from "../model/rest";
import RestModel from "model/rest";
import Thumb from "model/thumb";
import {Folder} from "model/folder";
import {Photo, TypeJpeg} from "model/photo";
export default {
name: 'p-page-folders',
name: 'p-page-files',
props: {
staticFilter: Object
},
@@ -154,6 +142,7 @@
this.filter.all = query['all'] ? query['all'] : '';
this.lastFilter = {};
this.routeName = this.$route.name;
this.path = this.$route.params.pathMatch;
this.search();
}
},
@@ -171,7 +160,6 @@
listen: false,
dirty: false,
results: [],
scrollDisabled: true,
loading: true,
pageSize: 24,
offset: 0,
@@ -181,6 +169,7 @@
filter: filter,
lastFilter: {},
routeName: routeName,
path: "",
labels: {
search: this.$gettext("Search"),
name: this.$gettext("Folder Name"),
@@ -191,9 +180,53 @@
timeStamp: -1,
},
lastId: "",
breadcrumbs: [],
};
},
methods: {
getBreadcrumbs() {
let result = [];
let path = "/files";
const crumbs = this.path.split("/");
crumbs.forEach(dir => {
if (dir) {
path += "/" + dir
result.push({path: path, name: dir})
}
})
return result;
},
openFile(index) {
const model = this.results[index];
if (model.isFile()) {
if (model.Type === TypeJpeg) {
const photo = new Photo({
UID: model.PhotoUID,
Title: model.Name,
TakenAt: model.Modified,
Description: "",
Favorite: false,
Files: [model]
});
this.$viewer.show(Thumb.fromPhotos([photo]), 0);
} else {
this.downloadFile(index);
}
} else {
this.$router.push({path: '/files/' + model.Path});
}
},
downloadFile(index) {
const model = this.results[index];
const link = document.createElement('a')
link.href = "/api/v1/download/" + model.Hash;
link.download = model.Name;
link.click()
},
selectRange(rangeEnd, models) {
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {
console.warn("selectRange() - invalid arguments:", rangeEnd, models);
@@ -242,6 +275,8 @@
} else {
this.toggleSelection(this.results[index].getId());
}
} else {
this.openFile(index);
}
},
onContextMenu(ev, index) {
@@ -249,7 +284,7 @@
ev.preventDefault();
ev.stopPropagation();
if(this.results[index]) {
if (this.results[index]) {
this.selectRange(index, this.results);
}
}
@@ -300,48 +335,6 @@
this.selection.splice(0, this.selection.length);
this.lastId = "";
},
loadMore() {
if (this.scrollDisabled) return;
this.scrollDisabled = true;
this.listen = false;
const count = this.dirty ? (this.page + 2) * this.pageSize : this.pageSize;
const offset = this.dirty ? 0 : this.offset;
const params = {
count: count,
offset: offset,
};
Object.assign(params, this.lastFilter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
Folder.originals("", params).then(response => {
this.results = this.dirty ? response.models : this.results.concat(response.models);
this.scrollDisabled = (response.models.length < count);
if (this.scrollDisabled) {
this.offset = offset;
if (this.results.length > 1) {
this.$notify.info(this.$gettext('All ') + this.results.length + this.$gettext(' folders loaded'));
}
} else {
this.offset = offset + count;
this.page++;
}
}).catch(() => {
this.scrollDisabled = false;
}).finally(() => {
this.dirty = false;
this.loading = false;
this.listen = true;
});
},
updateQuery() {
const query = {
view: this.settings.view
@@ -363,8 +356,7 @@
},
searchParams() {
const params = {
count: this.pageSize,
offset: this.offset,
files: true,
};
Object.assign(params, this.filter);
@@ -380,15 +372,11 @@
this.loading = true;
this.page = 0;
this.dirty = true;
this.scrollDisabled = false;
this.loadMore();
this.search();
},
search() {
this.scrollDisabled = true;
// Don't query the same data more than once
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
this.$nextTick(() => this.$emit("scrollRefresh"));
return;
}
@@ -401,19 +389,18 @@
const params = this.searchParams();
Folder.originals("", params).then(response => {
Folder.originals(this.path, params).then(response => {
this.offset = this.pageSize;
this.results = response.models;
this.breadcrumbs = this.getBreadcrumbs();
this.scrollDisabled = (response.models.length < this.pageSize);
if (this.scrollDisabled) {
this.$notify.info(this.results.length + this.$gettext(' folders found'));
if (response.count === 0) {
this.$notify.warn(this.$gettext('Directory is empty'));
} else if (response.count === 1) {
this.$notify.info(this.$gettext('One entry found'));
} else {
this.$notify.info(this.$gettext('More than 20 folders found'));
this.$nextTick(() => this.$emit("scrollRefresh"));
this.$notify.info(response.count + this.$gettext(' entries found'));
}
}).finally(() => {
this.dirty = false;
@@ -467,12 +454,13 @@
}
},
created() {
this.path = this.$route.params.pathMatch;
this.search();
this.subscriptions.push(Event.subscribe("folders", (ev, data) => this.onUpdate(ev, data)));
this.subscriptions.push(Event.subscribe("touchmove.top", () => this.refresh()));
this.subscriptions.push(Event.subscribe("touchmove.bottom", () => this.loadMore()));
},
destroyed() {
for (let i = 0; i < this.subscriptions.length; i++) {

View File

@@ -98,7 +98,7 @@
import Event from "pubsub-js";
import Settings from "model/settings";
import Util from "../../common/util";
import {Folder, FolderRootImport} from "model/folder";
import {Folder, RootImport} from "model/folder";
export default {
name: 'p-tab-import',
@@ -197,7 +197,7 @@
this.subscriptionId = Event.subscribe('import', this.handleEvent);
this.loading = true;
Folder.findAll(FolderRootImport).then((r) => {
Folder.findAll(RootImport).then((r) => {
const folders = r.models ? r.models : [];
const currentPath = this.settings.import.path;
let found = currentPath === this.root.path;

View File

@@ -91,7 +91,7 @@
import Event from "pubsub-js";
import Settings from "model/settings";
import Util from "common/util";
import { Folder, FolderRootOriginals } from "model/folder";
import { Folder, RootOriginals } from "model/folder";
export default {
name: 'p-tab-index',
@@ -206,7 +206,7 @@
this.subscriptionId = Event.subscribe('index', this.handleEvent);
this.loading = true;
Folder.findAll(FolderRootOriginals).then((r) => {
Folder.findAll(RootOriginals).then((r) => {
const folders = r.models ? r.models : [];
const currentPath = this.settings.index.path;
let found = currentPath === this.root.path;

View File

@@ -306,7 +306,7 @@
if (this.scrollDisabled) {
if (!this.results.length) {
this.$notify.warning(this.$gettext("No photos found"));
this.$notify.warn(this.$gettext("No photos found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One photo found"));
} else {

View File

@@ -83,7 +83,7 @@
this.$viewer.show(Thumb.fromPhotos(this.photos), index)
} else {
this.$notify.warning("No photos found");
this.$notify.warn("No photos found");
}
},
formChange() {
@@ -120,7 +120,7 @@
if (!response.data.features || response.data.features.length === 0) {
this.loading = false;
this.$notify.warning("No photos found");
this.$notify.warn("No photos found");
return;
}

View File

@@ -207,11 +207,11 @@
@change="onChange"
:disabled="busy"
class="ma-0 pa-0"
v-model="settings.features.folders"
v-model="settings.features.files"
color="secondary-dark"
:label="labels.folders"
:hint="hints.folders"
prepend-icon="folder"
:label="labels.files"
:hint="hints.files"
prepend-icon="insert_drive_file"
persistent-hint
>
</v-checkbox>
@@ -364,7 +364,7 @@
private: this.$gettext("Hide Private"),
review: this.$gettext("Quality Filter"),
places: this.$gettext("Places"),
folders: this.$gettext("Folders"),
files: this.$gettext("Files"),
moments: this.$gettext("Moments"),
labels: this.$gettext("Labels"),
import: this.$gettext("Import"),
@@ -381,7 +381,7 @@
group: this.$gettext("Files with sequential names like 'IMG_1234 (2)' or 'IMG_1234 copy 2' belong to the same photo."),
move: this.$gettext("Move files from import to originals to save storage. Unsupported file types will never be deleted, they remain in their current location."),
places: this.$gettext("Search and display photos on a map."),
folders: this.$gettext("Browse existing folders as albums."),
files: this.$gettext("Browse indexed files and folders."),
moments: this.$gettext("Let PhotoPrism create albums from past events."),
labels: this.$gettext("Browse and edit image classification labels."),
import: this.$gettext("Imported files will be sorted by date and given a unique name."),

View File

@@ -2,7 +2,7 @@ import Photos from "pages/photos.vue";
import Albums from "pages/albums.vue";
import AlbumPhotos from "pages/album/photos.vue";
import Places from "pages/places.vue";
import Folders from "pages/folders.vue";
import Files from "pages/files.vue";
import Labels from "pages/labels.vue";
import People from "pages/people.vue";
import Library from "pages/library.vue";
@@ -93,10 +93,10 @@ export default [
meta: {title: "Places", auth: true},
},
{
name: "folders",
path: "/folders",
component: Folders,
meta: {title: "Folders", auth: true},
name: "files",
path: "/files*",
component: Files,
meta: {title: "Originals", auth: true},
},
{
name: "labels",

View File

@@ -10,8 +10,8 @@ describe("common/alert", () => {
});
it("should call alert.warning", () => {
let spy = sinon.spy(Notify, "warning");
Notify.warning("message");
let spy = sinon.spy(Notify, "warn");
Notify.warn("message");
sinon.assert.calledOnce(spy);
spy.resetHistory();
});

View File

@@ -3,17 +3,27 @@ package api
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
)
type FoldersResponse struct {
Root string `json:"root,omitempty"`
Folders []entity.Folder `json:"folders"`
Files []entity.File `json:"files,omitempty"`
Recursive bool `json:"recursive,omitempty"`
}
// GetFolders is a reusable request handler for directory listings (GET /api/v1/folders/*).
func GetFolders(router *gin.RouterGroup, conf *config.Config, root, pathName string) {
router.GET("/folders/"+root, func(c *gin.Context) {
func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath string) {
handler := func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
@@ -22,35 +32,53 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, root, pathName str
start := time.Now()
gc := service.Cache()
recursive := c.Query("recursive") != ""
listFiles := c.Query("files") != ""
resp := FoldersResponse{Root: root, Recursive: recursive}
path := c.Param("path")
cacheKey := fmt.Sprintf("folders:%s:%t", pathName, recursive)
cacheKey := fmt.Sprintf("folders:%s:%t:%t", filepath.Join(rootPath, path), recursive, listFiles)
if cacheData, ok := gc.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cacheData.([]entity.Folder))
c.JSON(http.StatusOK, cacheData.(FoldersResponse))
return
}
folders, err := entity.Folders(root, pathName, recursive)
if err != nil {
if folders, err := query.FoldersByPath(root, rootPath, path, recursive); err != nil {
log.Errorf("folders: %s", err)
c.JSON(http.StatusOK, resp)
return
} else {
gc.Set(cacheKey, folders, time.Minute*5)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
resp.Folders = folders
}
c.JSON(http.StatusOK, folders)
})
if listFiles {
if files, err := query.FilesByPath(root, path); err != nil {
log.Errorf("folders: %s", err)
} else {
resp.Files = files
}
}
gc.Set(cacheKey, resp, time.Minute*5)
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
c.Header("X-Count", strconv.Itoa(len(resp.Files) + len(resp.Folders)))
c.Header("X-Offset", "0")
c.JSON(http.StatusOK, resp)
}
router.GET("/folders/"+root, handler)
router.GET("/folders/"+root+"/*path", handler)
}
// GET /api/v1/folders/originals
func GetFoldersOriginals(router *gin.RouterGroup, conf *config.Config) {
GetFolders(router, conf, entity.FolderRootOriginals, conf.OriginalsPath())
GetFolders(router, conf, entity.RootOriginals, conf.OriginalsPath())
}
// GET /api/v1/folders/import
func GetFoldersImport(router *gin.RouterGroup, conf *config.Config) {
GetFolders(router, conf, entity.FolderRootImport, conf.ImportPath())
GetFolders(router, conf, entity.RootImport, conf.ImportPath())
}

View File

@@ -22,13 +22,17 @@ func TestGetFoldersOriginals(t *testing.T) {
GetFoldersOriginals(router, conf)
r := PerformRequest(app, "GET", "/api/v1/folders/originals")
var folders []entity.Folder
err = json.Unmarshal(r.Body.Bytes(), &folders)
// t.Logf("RESPONSE: %s", r.Body.Bytes())
var resp FoldersResponse
err = json.Unmarshal(r.Body.Bytes(), &resp)
if err != nil {
t.Fatal(err)
}
folders := resp.Folders
if len(folders) != len(expected) {
t.Fatalf("response contains %d folders", len(folders))
}
@@ -42,7 +46,7 @@ func TestGetFoldersOriginals(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.FolderRootOriginals, folder.Root)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderHidden)
@@ -62,13 +66,18 @@ func TestGetFoldersOriginals(t *testing.T) {
}
GetFoldersOriginals(router, conf)
r := PerformRequest(app, "GET", "/api/v1/folders/originals?recursive=true")
var folders []entity.Folder
err = json.Unmarshal(r.Body.Bytes(), &folders)
// t.Logf("RESPONSE: %s", r.Body.Bytes())
var resp FoldersResponse
err = json.Unmarshal(r.Body.Bytes(), &resp)
if err != nil {
t.Fatal(err)
}
folders := resp.Folders
if len(folders) != len(expected) {
t.Fatalf("response contains %d folders", len(folders))
}
@@ -77,7 +86,7 @@ func TestGetFoldersOriginals(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.FolderRootOriginals, folder.Root)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderHidden)
@@ -101,13 +110,18 @@ func TestGetFoldersImport(t *testing.T) {
GetFoldersImport(router, conf)
r := PerformRequest(app, "GET", "/api/v1/folders/import")
var folders []entity.Folder
err = json.Unmarshal(r.Body.Bytes(), &folders)
// t.Logf("RESPONSE: %s", r.Body.Bytes())
var resp FoldersResponse
err = json.Unmarshal(r.Body.Bytes(), &resp)
if err != nil {
t.Fatal(err)
}
folders := resp.Folders
if len(folders) != len(expected) {
t.Fatalf("response contains %d folders", len(folders))
}
@@ -123,7 +137,7 @@ func TestGetFoldersImport(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.FolderRootImport, folder.Root)
assert.Equal(t, entity.RootImport, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderHidden)
@@ -143,13 +157,16 @@ func TestGetFoldersImport(t *testing.T) {
GetFoldersImport(router, conf)
r := PerformRequest(app, "GET", "/api/v1/folders/import?recursive=true")
var folders []entity.Folder
err = json.Unmarshal(r.Body.Bytes(), &folders)
var resp FoldersResponse
err = json.Unmarshal(r.Body.Bytes(), &resp)
if err != nil {
t.Fatal(err)
}
folders := resp.Folders
if len(folders) != len(expected) {
t.Fatalf("response contains %d folders", len(folders))
}
@@ -158,7 +175,7 @@ func TestGetFoldersImport(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.FolderRootImport, folder.Root)
assert.Equal(t, entity.RootImport, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderHidden)

View File

@@ -10,6 +10,9 @@ var photoIconSvg = []byte(`
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4.86 8.86l-3 3.87L9 13.14 6 17h12l-3.86-5.14z"/></svg>`)
var fileIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/><path d="M0 0h24v24H0z" fill="none"/></svg>`)
var videoIconSvg = []byte(`<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<path d="M0 0h24v24H0z" fill="none"/><path d="M10 8v8l5-4-5-4zm9-5H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>`)
@@ -36,6 +39,10 @@ func GetSvg(router *gin.RouterGroup) {
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
})
router.GET("/svg/file", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", fileIconSvg)
})
router.GET("/svg/video", func(c *gin.Context) {
c.Data(http.StatusOK, "image/svg+xml", videoIconSvg)
})

View File

@@ -58,19 +58,21 @@ func (c *Config) PublicClientConfig() ClientConfig {
}{}
var count = struct {
Photos uint `json:"photos"`
Videos uint `json:"videos"`
Hidden uint `json:"hidden"`
Favorites uint `json:"favorites"`
Private uint `json:"private"`
Review uint `json:"review"`
Stories uint `json:"stories"`
Labels uint `json:"labels"`
Albums uint `json:"albums"`
Folders uint `json:"folders"`
Moments uint `json:"moments"`
Countries uint `json:"countries"`
Places uint `json:"places"`
Photos uint `json:"photos"`
Videos uint `json:"videos"`
Hidden uint `json:"hidden"`
Favorites uint `json:"favorites"`
Private uint `json:"private"`
Review uint `json:"review"`
Stories uint `json:"stories"`
Albums uint `json:"albums"`
Folders uint `json:"folders"`
Files uint `json:"files"`
Moments uint `json:"moments"`
Countries uint `json:"countries"`
Places uint `json:"places"`
Labels uint `json:"labels"`
LabelMaxPhotos uint `json:"labelMaxPhotos"`
}{}
result := ClientConfig{
@@ -144,6 +146,7 @@ func (c *Config) ClientConfig() ClientConfig {
Review uint `json:"review"`
Albums uint `json:"albums"`
Folders uint `json:"folders"`
Files uint `json:"files"`
Moments uint `json:"moments"`
Countries uint `json:"countries"`
Places uint `json:"places"`
@@ -175,6 +178,12 @@ func (c *Config) ClientConfig() ClientConfig {
Where("deleted_at IS NULL").
Take(&count)
db.Table("files").
Select("COUNT(*) AS files").
Where("file_missing = 0").
Where("deleted_at IS NULL").
Take(&count)
db.Table("countries").
Select("(COUNT(*) - 1) AS countries").
Take(&count)

View File

@@ -42,7 +42,7 @@ type FeatureSettings struct {
Review bool `json:"review" yaml:"review"`
Upload bool `json:"upload" yaml:"upload"`
Import bool `json:"import" yaml:"import"`
Folders bool `json:"folders" yaml:"folders"`
Files bool `json:"files" yaml:"files"`
Moments bool `json:"moments" yaml:"moments"`
Labels bool `json:"labels" yaml:"labels"`
Places bool `json:"places" yaml:"places"`
@@ -81,7 +81,7 @@ func NewSettings() *Settings {
Private: true,
Upload: true,
Import: true,
Folders: true,
Files: true,
Moments: true,
Labels: true,
Places: true,

View File

@@ -11,7 +11,7 @@ features:
review: true
upload: true
import: true
folders: true
files: true
moments: true
labels: true
places: true

View File

@@ -31,7 +31,7 @@ var UnknownCamera = Camera{
// CreateUnknownCamera initializes the database with an unknown camera if not exists
func CreateUnknownCamera() {
UnknownCamera.FirstOrCreate()
FirstOrCreateCamera(&UnknownCamera)
}
// NewCamera creates a camera entity from a model name and a make name.
@@ -71,6 +71,29 @@ func (m *Camera) FirstOrCreate() *Camera {
return m
}
// Create inserts a new row to the database.
func (m *Camera) Create() error {
if err := Db().Create(m).Error; err != nil {
return err
}
return nil
}
// FirstOrCreateCamera inserts a new row if not exists.
func FirstOrCreateCamera(m *Camera) *Camera {
result := Camera{}
if err := Db().Where("camera_model = ? AND camera_make = ?", m.CameraModel, m.CameraMake).First(&result).Error; err == nil {
return &result
} else if err := m.Create(); err != nil {
log.Errorf("camera: %s", err)
return nil
}
return m
}
// String returns a string designing the given Camera entity
func (m *Camera) String() string {
if m.CameraMake != "" && m.CameraModel != "" {

View File

@@ -6,11 +6,17 @@ import (
"github.com/stretchr/testify/assert"
)
func TestCamera_FirstOrCreate(t *testing.T) {
func TestFirstOrCreateCamera(t *testing.T) {
t.Run("iphone-se", func(t *testing.T) {
camera := NewCamera("iPhone SE", "Apple")
camera.FirstOrCreate()
assert.GreaterOrEqual(t, camera.ID, uint(1))
result := FirstOrCreateCamera(camera)
if result == nil {
t.Fatal("result should not be nil")
}
assert.GreaterOrEqual(t, result.ID, uint(1))
})
}

View File

@@ -34,4 +34,8 @@ const (
TypeVideo = "video"
TypeRaw = "raw"
TypeText = "text"
RootOriginals = "originals"
RootImport = "import"
RootPath = "/"
)

View File

@@ -18,7 +18,8 @@ type File struct {
PhotoID uint `gorm:"index;" json:"-" yaml:"-"`
PhotoUID string `gorm:"type:varbinary(36);index;" json:"PhotoUID" yaml:"PhotoUID"`
FileUID string `gorm:"type:varbinary(36);unique_index;" json:"UID" yaml:"UID"`
FileName string `gorm:"type:varbinary(768);unique_index" json:"Name" yaml:"Name"`
FileName string `gorm:"type:varbinary(768);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:varbinary(16);default:'originals';unique_index:idx_files_name_root;" json:"Root" yaml:"Root"`
OriginalName string `gorm:"type:varbinary(768);" json:"OriginalName" yaml:"OriginalName,omitempty"`
FileHash string `gorm:"type:varbinary(128);index" json:"Hash" yaml:"Hash,omitempty"`
FileModified time.Time `json:"Modified" yaml:"Modified,omitempty"`
@@ -37,7 +38,7 @@ type File struct {
FileHeight int `json:"Height" yaml:"Height,omitempty"`
FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"`
FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"`
FileMainColor string `gorm:"type:varbinary(16);index;" json:"eMainColor" yaml:"eMainColor,omitempty"`
FileMainColor string `gorm:"type:varbinary(16);index;" json:"MainColor" yaml:"MainColor,omitempty"`
FileColors string `gorm:"type:varbinary(9);" json:"Colors" yaml:"Colors,omitempty"`
FileLuminance string `gorm:"type:varbinary(9);" json:"Luminance" yaml:"Luminance,omitempty"`
FileDiff uint32 `json:"Diff" yaml:"Diff,omitempty"`

View File

@@ -9,23 +9,15 @@ import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/ulule/deepcopier"
)
const (
FolderRootUnknown = ""
FolderRootOriginals = "originals"
FolderRootImport = "import"
RootPath = "/"
)
// Folder represents a file system directory.
type Folder struct {
Root string `gorm:"type:varbinary(255);unique_index:idx_folders_root_path;" json:"Root" yaml:"Root"`
Path string `gorm:"type:varbinary(1024);unique_index:idx_folders_root_path;" json:"Path" yaml:"Path"`
Path string `gorm:"type:varbinary(255);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`
Root string `gorm:"type:varbinary(16);default:'originals';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root"`
FolderUID string `gorm:"type:varbinary(36);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
FolderTitle string `gorm:"type:varchar(255);" json:"Title" yaml:"Title,omitempty"`
FolderDescription string `gorm:"type:text;" json:"Description,omitempty" yaml:"Description,omitempty"`
@@ -119,6 +111,18 @@ func (m *Folder) Create() error {
return nil
}
// FirstOrCreateFolder finds the first matching record or creates a new one with the given conditions.
func FirstOrCreateFolder(m *Folder) error {
first := Folder{}
if err := Db().Where("path = ? AND root = ?", m.Path, m.Root).First(&first).Error; err == nil {
m = &first
return nil
}
return m.Create()
}
// Updates selected properties in the database.
func (m *Folder) Updates(values interface{}) error {
return Db().Model(m).Updates(values).Error
@@ -132,20 +136,3 @@ func (m *Folder) SetForm(f form.Folder) error {
return nil
}
// Returns a slice of folders in a given directory incl sub directories in recursive mode.
func Folders(root, dir string, recursive bool) ([]Folder, error) {
dirs, err := fs.Dirs(dir, recursive)
if err != nil {
return []Folder{}, err
}
folders := make([]Folder, len(dirs))
for i, p := range dirs {
folders[i] = NewFolder(root, p, nil)
}
return folders, nil
}

View File

@@ -8,8 +8,8 @@ import (
func TestNewFolder(t *testing.T) {
t.Run("2020/05", func(t *testing.T) {
folder := NewFolder(FolderRootOriginals, "2020/05", nil)
assert.Equal(t, FolderRootOriginals, folder.Root)
folder := NewFolder(RootOriginals, "2020/05", nil)
assert.Equal(t, RootOriginals, folder.Root)
assert.Equal(t, "2020/05", folder.Path)
assert.Equal(t, "May 2020", folder.FolderTitle)
assert.Equal(t, "", folder.FolderDescription)
@@ -23,31 +23,31 @@ func TestNewFolder(t *testing.T) {
})
t.Run("/2020/05/01/", func(t *testing.T) {
folder := NewFolder(FolderRootOriginals, "/2020/05/01/", nil)
folder := NewFolder(RootOriginals, "/2020/05/01/", nil)
assert.Equal(t, "2020/05/01", folder.Path)
assert.Equal(t, "May 2020", folder.FolderTitle)
})
t.Run("/2020/05/23/", func(t *testing.T) {
folder := NewFolder(FolderRootImport, "/2020/05/23/", nil)
folder := NewFolder(RootImport, "/2020/05/23/", nil)
assert.Equal(t, "2020/05/23", folder.Path)
assert.Equal(t, "May 23, 2020", folder.FolderTitle)
})
t.Run("/2020/05/23 Birthday", func(t *testing.T) {
folder := NewFolder(FolderRootUnknown, "/2020/05/23 Birthday", nil)
folder := NewFolder(RootOriginals, "/2020/05/23 Birthday", nil)
assert.Equal(t, "2020/05/23 Birthday", folder.Path)
assert.Equal(t, "23 Birthday", folder.FolderTitle)
})
t.Run("empty", func(t *testing.T) {
folder := NewFolder(FolderRootOriginals, "", nil)
folder := NewFolder(RootOriginals, "", nil)
assert.Equal(t, "", folder.Path)
assert.Equal(t, "Originals", folder.FolderTitle)
})
t.Run("root", func(t *testing.T) {
folder := NewFolder(FolderRootOriginals, RootPath, nil)
folder := NewFolder(RootOriginals, RootPath, nil)
assert.Equal(t, "", folder.Path)
assert.Equal(t, "Originals", folder.FolderTitle)
})

View File

@@ -29,7 +29,7 @@ var UnknownLens = Lens{
// CreateUnknownLens initializes the database with an unknown lens if not exists
func CreateUnknownLens() {
UnknownLens.FirstOrCreate()
FirstOrCreateLens(&UnknownLens)
}
// TableName returns Lens table identifier "lens"
@@ -56,10 +56,24 @@ func NewLens(modelName string, makeName string) *Lens {
return result
}
// FirstOrCreate checks if the lens already exists in the database
func (m *Lens) FirstOrCreate() *Lens {
if err := Db().FirstOrCreate(m, "lens_slug = ?", m.LensSlug).Error; err != nil {
// Create inserts a new row to the database.
func (m *Lens) Create() error {
if err := Db().Create(m).Error; err != nil {
return err
}
return nil
}
// FirstOrCreateLens inserts a new row if not exists.
func FirstOrCreateLens(m *Lens) *Lens {
result := Lens{}
if err := Db().Where("lens_slug = ?", m.LensSlug).First(&result).Error; err == nil {
return &result
} else if err := m.Create(); err != nil {
log.Errorf("lens: %s", err)
return nil
}
return m

View File

@@ -117,7 +117,7 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if isDir && result != filepath.SkipDir {
folder := entity.NewFolder(entity.FolderRootImport, fs.RelativeName(fileName, imp.conf.ImportPath()), nil)
folder := entity.NewFolder(entity.RootImport, fs.RelativeName(fileName, imp.conf.ImportPath()), nil)
if err := folder.Create(); err == nil {
log.Infof("import: added folder /%s", folder.Path)

View File

@@ -121,7 +121,7 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if isDir && result != filepath.SkipDir {
folder := entity.NewFolder(entity.FolderRootOriginals, fs.RelativeName(fileName, originalsPath), nil)
folder := entity.NewFolder(entity.RootOriginals, fs.RelativeName(fileName, originalsPath), nil)
if err := folder.Create(); err == nil {
log.Infof("index: added folder /%s", folder.Path)

View File

@@ -70,6 +70,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
fileBase := m.Base(ind.conf.Settings().Index.Group)
filePath := m.RelativePath(ind.originalsPath())
fileRoot := entity.RootOriginals
fileName := m.RelativeName(ind.originalsPath())
fileHash := ""
fileSize, fileModified := m.Stat()
@@ -296,8 +297,8 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if photo.CameraSrc == entity.SrcAuto {
// Set UpdateCamera, Lens, Focal Length and F Number.
photo.Camera = entity.NewCamera(m.CameraModel(), m.CameraMake()).FirstOrCreate()
photo.Lens = entity.NewLens(m.LensModel(), m.LensMake()).FirstOrCreate()
photo.Camera = entity.FirstOrCreateCamera(entity.NewCamera(m.CameraModel(), m.CameraMake()))
photo.Lens = entity.FirstOrCreateLens(entity.NewLens(m.LensModel(), m.LensMake()))
photo.PhotoFocalLength = m.FocalLength()
photo.PhotoFNumber = m.FNumber()
photo.PhotoIso = m.Iso()
@@ -335,6 +336,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
file.FileSidecar = m.IsSidecar()
file.FileVideo = m.IsVideo()
file.FileRoot = fileRoot
file.FileName = fileName
file.FileHash = fileHash
file.FileSize = fileSize
@@ -474,6 +476,10 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
return result
}
event.Publish("count.files", event.Data{
"count": 1,
})
result.Status = IndexAdded
}

View File

@@ -6,16 +6,35 @@ import (
"github.com/photoprism/photoprism/internal/entity"
)
type Files []entity.File
// FilesByPath returns a slice of files in a given originals folder.
func FilesByPath(root, pathName string) (files Files, err error) {
if strings.HasPrefix(pathName, "/") {
pathName = pathName[1:]
}
err = Db().
Table("files").Select("files.*").
Joins("JOIN photos ON photos.id = files.photo_id AND photos.deleted_at IS NULL").
Where("files.file_missing = 0").
Where("files.file_root = ? AND photos.photo_path = ?", root, pathName).
Order("files.file_name").
Find(&files).Error
return files, err
}
// ExistingFiles returns not-missing and not-deleted file entities in the range of limit and offset sorted by id.
func ExistingFiles(limit int, offset int, filePath string) (files []entity.File, err error) {
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
func ExistingFiles(limit int, offset int, pathName string) (files Files, err error) {
if strings.HasPrefix(pathName, "/") {
pathName = pathName[1:]
}
stmt := Db().Unscoped().Where("file_missing = 0 AND deleted_at IS NULL")
if filePath != "" {
stmt = stmt.Where("file_name LIKE ?", filePath+"/%")
if pathName != "" {
stmt = stmt.Where("files.file_name LIKE ?", pathName+"/%")
}
err = stmt.Order("id").Limit(limit).Offset(offset).Find(&files).Error
@@ -24,7 +43,7 @@ func ExistingFiles(limit int, offset int, filePath string) (files []entity.File,
}
// FilesByUID
func FilesByUID(u []string, limit int, offset int) (files []entity.File, err error) {
func FilesByUID(u []string, limit int, offset int) (files Files, err error) {
if err := Db().Where("(photo_uid IN (?) AND file_primary = 1) OR file_uid IN (?)", u, u).Preload("Photo").Limit(limit).Offset(offset).Find(&files).Error; err != nil {
return files, err
}

View File

@@ -7,6 +7,20 @@ import (
"github.com/stretchr/testify/assert"
)
func TestFilesByPath(t *testing.T) {
t.Run("files found", func(t *testing.T) {
files, err := FilesByPath(entity.RootOriginals, "2016/11")
t.Logf("files: %+v", files)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(files))
})
}
func TestExistingFiles(t *testing.T) {
t.Run("files found", func(t *testing.T) {
files, err := ExistingFiles(1000, 0, "/")
@@ -26,6 +40,7 @@ func TestExistingFiles(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Empty(t, files)
})
}

32
internal/query/folders.go Normal file
View File

@@ -0,0 +1,32 @@
package query
import (
"path/filepath"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
)
type Folders []entity.Folder
// FoldersByPath returns a slice of folders in a given directory incl sub directories in recursive mode.
func FoldersByPath(root, rootPath, path string, recursive bool) (folders Folders, err error) {
dirs, err := fs.Dirs(filepath.Join(rootPath, path), recursive)
if err != nil {
return folders, err
}
folders = make(Folders, len(dirs))
for i, dir := range dirs {
folder := entity.NewFolder(root, filepath.Join(path, dir), nil)
if err := entity.FirstOrCreateFolder(&folder); err != nil {
log.Errorf("folders: %s", err)
}
folders[i] = folder
}
return folders, nil
}

View File

@@ -0,0 +1,34 @@
package query
import (
"testing"
"github.com/photoprism/photoprism/internal/entity"
"github.com/stretchr/testify/assert"
)
func TestFoldersByPath(t *testing.T) {
t.Run("root", func(t *testing.T) {
folders, err := FoldersByPath(entity.RootOriginals, "testdata", "", false)
t.Logf("folders: %+v", folders)
if err != nil {
t.Fatal(err)
}
assert.Len(t, folders, 1)
})
t.Run("subdirectory", func(t *testing.T) {
folders, err := FoldersByPath(entity.RootOriginals, "testdata", "directory", false)
t.Logf("folders: %+v", folders)
if err != nil {
t.Fatal(err)
}
assert.Len(t, folders, 2)
})
}

View File

@@ -26,9 +26,10 @@ func Photos(f form.PhotoSearch) (results PhotosResults, count int, err error) {
// Main search query, avoids (slow) left joins.
s = s.Table("photos").
Select(`photos.*,
files.id AS file_id, files.file_uid, files.file_primary, files.file_missing, files.file_name, files.file_hash,
files.file_codec, files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio,
files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance, files.file_chroma,
files.id AS file_id, files.file_uid, files.file_primary, files.file_missing, files.file_name,
files.file_root, files.file_hash, files.file_codec, files.file_type, files.file_mime, files.file_width,
files.file_height, files.file_aspect_ratio, files.file_orientation, files.file_main_color,
files.file_colors, files.file_luminance, files.file_chroma,
files.file_diff, files.file_video, files.file_duration, files.file_size,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,

View File

@@ -53,6 +53,7 @@ type PhotosResult struct {
LocCountry string `json:"LocCountry"`
FileID uint `json:"-"` // File
FileUID string `json:"FileUID"`
FileRoot string `json:"FileRoot"`
FileName string `json:"FileName"`
FileHash string `json:"Hash"`
FileWidth int `json:"Width"`

View File

@@ -0,0 +1 @@
example text file, you can ignore this

View File

@@ -0,0 +1,3 @@
# Example
Foo bar baz

View File

@@ -0,0 +1,3 @@
# Example
Foo bar baz

View File

@@ -0,0 +1 @@
example text file, you can ignore this