mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Add simple file browser to Library #260
Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
@@ -11,6 +11,8 @@ features:
|
||||
review: true
|
||||
upload: true
|
||||
import: true
|
||||
files: true
|
||||
moments: true
|
||||
labels: true
|
||||
places: true
|
||||
download: true
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
5
frontend/src/css/files.css
Normal file
5
frontend/src/css/files.css
Normal file
@@ -0,0 +1,5 @@
|
||||
#photoprism .p-folders-cards .p-folder-like,
|
||||
#photoprism .p-files-cards .p-files-like {
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#photoprism .p-folders-cards .p-folder-like {
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
197
frontend/src/model/file.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++) {
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
internal/config/testdata/configEmpty.yml
vendored
2
internal/config/testdata/configEmpty.yml
vendored
@@ -11,7 +11,7 @@ features:
|
||||
review: true
|
||||
upload: true
|
||||
import: true
|
||||
folders: true
|
||||
files: true
|
||||
moments: true
|
||||
labels: true
|
||||
places: true
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@ const (
|
||||
TypeVideo = "video"
|
||||
TypeRaw = "raw"
|
||||
TypeText = "text"
|
||||
|
||||
RootOriginals = "originals"
|
||||
RootImport = "import"
|
||||
RootPath = "/"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
32
internal/query/folders.go
Normal 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
|
||||
}
|
||||
34
internal/query/folders_test.go
Normal file
34
internal/query/folders_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
1
internal/query/testdata/directory/dir 1/foo.txt
vendored
Normal file
1
internal/query/testdata/directory/dir 1/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
example text file, you can ignore this
|
||||
3
internal/query/testdata/directory/dir 1/level 1/level 2/level 3/foo.md
vendored
Normal file
3
internal/query/testdata/directory/dir 1/level 1/level 2/level 3/foo.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Example
|
||||
|
||||
Foo bar baz
|
||||
3
internal/query/testdata/directory/dir 2/foo.md
vendored
Normal file
3
internal/query/testdata/directory/dir 2/foo.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Example
|
||||
|
||||
Foo bar baz
|
||||
1
internal/query/testdata/directory/dir 2/level 1/level 2/level 3/foo.txt
vendored
Normal file
1
internal/query/testdata/directory/dir 2/level 1/level 2/level 3/foo.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
example text file, you can ignore this
|
||||
Reference in New Issue
Block a user