Proof-of-concept for upload & import

This commit is contained in:
Michael Mayer 2019-06-13 11:26:01 -07:00
parent 3433199c08
commit 60e9346f08
10 changed files with 299 additions and 52 deletions

View file

@ -175,6 +175,7 @@ windsor tie:
chainlink fence:
label: fence
priority: -1
mitten:
label: unknown
@ -2727,6 +2728,7 @@ photocopier:
- office
picket fence:
priority: -1
categories:
- fence
@ -3128,6 +3130,7 @@ wooden spoon:
worm fence:
label: fence
priority: -1
wreck:
categories:
@ -3513,3 +3516,15 @@ wool:
ear:
priority: -1
sunglass:
label: sunglasses
priority: 2
packet:
label: package
priority: 1
swing:
label: relax
priority: 1

View file

@ -1,38 +1,136 @@
<template>
<div>
<v-toolbar flat color="blue-grey lighten-4">
<h1>Import</h1>
</v-toolbar>
<v-container fluid>
<v-form>
<p class="md-subheading">
You have two possibilities to get your photos into photoprism.</p>
<h2>Import & Index</h2>
<p>Importing means the photos you upload are renamed (the naming schema you can define in settings), and moved to the originals folder sorted by year and month.
Additionally duplicates are removed and images get tagged and metadata (like location, camera model etc.) will be extracted. In case you have not supported file types
(e.g. videos) within the folder you import --> those are ignored. </p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn">Import & Index</v-btn>
<input v-show="false" ref="inputUpload" type="file" >
<div class="p-page p-page-import">
<v-form ref="form" class="p-photo-import" lazy-validation @submit.prevent="submit" dense>
<input type="file" ref="upload" multiple @change.stop="upload()" class="d-none">
<v-toolbar flat color="blue-grey lighten-4">
<v-toolbar-title>Import</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click.stop="uploadDialog()">
<v-icon>cloud_upload</v-icon>
</v-btn>
</v-toolbar>
<v-container fluid>
<v-progress-linear color="accent" v-model="completed"></v-progress-linear>
<v-container grid-list-xs fluid class="pa-0 p-photos p-photo-mosaic">
<p class="subheading" v-if="uploads.length === 0">
Select photos to start import...
</p>
<v-layout row wrap>
<v-flex
v-for="(file, index) in uploads"
:key="index"
class="p-photo"
xs4 sm3 md2 lg1 d-flex
>
<v-card tile class="elevation-2 ma-2">
<v-img :src="file.data"
aspect-ratio="1"
:title="file.name"
class="grey lighten-2"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
>
<v-progress-circular indeterminate
color="grey lighten-5"></v-progress-circular>
</v-layout>
</v-img>
</v-card>
</v-flex>
</v-layout>
<p class="subheading" v-if="completed === 100">
Done.
</p>
</v-container>
</v-container>
</v-form>
</v-container>
<v-container fluid>
<v-form>
<h2>Index</h2>
<p>In case you already have a nice folder structure you can only index the photos. Therefore in settings you need to set the base directory to the directory your photos
are in. The index functionality will then just tag the images and extract the metadata.
</p>
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn">Index</v-btn>
</v-form>
</v-container>
</div>
</div>
</template>
<script>
import axios from "axios";
import Event from "pubsub-js";
export default {
name: 'import',
props: {},
name: 'p-page-import',
data() {
return {}
return {
selected: [],
uploads: [],
busy: false,
current: 0,
total: 0,
completed: 0,
}
},
methods: {
submit() {
console.log("SUBMIT");
},
uploadDialog() {
this.$refs.upload.click();
},
upload() {
this.$alert.info("Uploading photos...");
Event.publish("ajax.start");
this.selected = this.$refs.upload.files;
this.busy = true;
this.total = this.selected.length;
this.current = 0;
this.completed = 0;
async function performUpload(ctx) {
for (let i = 0; i < ctx.selected.length; i++) {
ctx.current = i + 1;
ctx.completed = Math.round((ctx.current / ctx.total) * 100);
let file = ctx.selected[i];
let formData = new FormData();
formData.append('files', file);
if (file.type.match('image.*')) {
const reader = new FileReader;
reader.onload = e => {
ctx.uploads.push({name: file.name, data: e.target.result});
};
reader.readAsDataURL(file)
}
await axios.post('/api/v1/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
).then(function () {
}).catch(function () {
Event.publish("alert.error", "Upload failed");
});
}
}
performUpload(this).then(() => {
Event.publish("ajax.end");
Event.publish("alert.success", "Photos uploaded and imported");
this.busy = false;
});
},
}
};
</script>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="p-page p-page-todo">
<v-toolbar flat color="blue-grey lighten-4">
<v-toolbar-title>Not implemented yet</v-toolbar-title>
@ -20,7 +20,7 @@
<script>
export default {
name: 'todo',
name: 'p-page-todo',
data() {
return {};
},

View file

@ -3,6 +3,7 @@ import Places from "pages/places.vue";
import Labels from "pages/labels.vue";
import Events from "pages/events.vue";
import People from "pages/people.vue";
import Import from "pages/import.vue";
import Share from "pages/share.vue";
import Settings from "pages/settings.vue";
import Todo from "pages/todo.vue";
@ -65,7 +66,7 @@ export default [
{
name: "Import",
path: "/import",
component: Todo,
component: Import,
meta: {area: "Import"},
},
{

View file

@ -2,6 +2,7 @@ package api
import (
"fmt"
"net/http"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
@ -25,7 +26,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !ok {
log.Errorf("invalid type: %s", typeName)
c.Data(400, "image/svg+xml", photoIconSvg)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
return
}
@ -33,7 +34,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
file, err := search.FindFileByHash(fileHash)
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": err.Error()})
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
@ -41,7 +42,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !util.Exists(fileName) {
log.Errorf("could not find original for thumbnail: %s", fileName)
c.Data(404, "image/svg+xml", photoIconSvg)
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
file.FileMissing = true
@ -59,7 +60,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
c.File(thumbnail)
} else {
log.Errorf("could not create thumbnail: %s", err)
c.Data(400, "image/svg+xml", photoIconSvg)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
}
})
}
@ -79,7 +80,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !ok {
log.Errorf("invalid type: %s", typeName)
c.Data(400, "image/svg+xml", photoIconSvg)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
return
}
@ -92,7 +93,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
// log.Infof("Label thumb file: %#v", file)
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": util.UcFirst(err.Error())})
return
}
@ -100,7 +101,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
if !util.Exists(fileName) {
log.Errorf("could not find original for thumbnail: %s", fileName)
c.Data(404, "image/svg+xml", photoIconSvg)
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore
file.FileMissing = true
@ -118,7 +119,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
c.File(thumbnail)
} else {
log.Errorf("could not create thumbnail: %s", err)
c.Data(400, "image/svg+xml", photoIconSvg)
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
}
})
}

80
internal/api/upload.go Normal file
View file

@ -0,0 +1,80 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/util"
uuid "github.com/satori/go.uuid"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
)
var importer *photoprism.Importer
func initImporter(conf *config.Config) {
if importer != nil {
return
}
tensorFlow := photoprism.NewTensorFlow(conf)
indexer := photoprism.NewIndexer(conf, tensorFlow)
converter := photoprism.NewConverter(conf)
importer = photoprism.NewImporter(conf, indexer, converter)
}
// POST /api/v1/upload
func Upload(router *gin.RouterGroup, conf *config.Config) {
router.POST("/upload", func(c *gin.Context) {
start := time.Now()
form, err := c.MultipartForm()
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
log.Debugf("Value: %#v", form.Value)
log.Debugf("File: %#v", form.File)
files := form.File["files"]
path := fmt.Sprintf("%s/uploads/%s", conf.ImportPath(), uuid.NewV4())
if err := os.MkdirAll(path, os.ModePerm); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
for _, file := range files {
filename := fmt.Sprintf("%s/%s", path, filepath.Base(file.Filename))
if err := c.SaveUploadedFile(file, filename); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
return
}
}
log.Infof("importing photos from %s", conf.ImportPath())
initImporter(conf)
importer.ImportPhotosFromDirectory(conf.ImportPath())
elapsed := time.Since(start)
log.Infof("%d files imported in %s", len(files), elapsed)
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d files imported in %s", len(files), elapsed)})
})
}

View file

@ -11,6 +11,7 @@ import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/util"
)
const (
@ -176,22 +177,30 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 60 && labels[0].Name != "" { // TODO: User defined title format
log.Infof("label for title: %#v", labels[0])
if location.LocCity == "" || len(location.LocCity) > 16 || strings.Contains(labels[0].Name, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocName != "" && location.LocCity != "" {
if len(location.LocName) > 45 {
photo.PhotoTitle = strings.Title(location.LocName)
photo.PhotoTitle = util.Title(location.LocName)
} else if len(location.LocName) > 20 || len(location.LocCity) > 16 || strings.Contains(location.LocName, location.LocCity) {
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(location.LocName), photo.TakenAt.Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(location.LocName), photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
}
} else if location.LocCity != "" && location.LocCountry != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
if len(location.LocCity) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCity, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
}
} else if location.LocCounty != "" && location.LocCountry != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
if len(location.LocCounty) > 20 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCounty, photo.TakenAt.Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
}
}
}
} else {
@ -200,7 +209,7 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
if photo.PhotoTitleChanged == false && photo.PhotoTitle == "" {
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
} else {
var daytimeString string
hour := mediaFile.DateCreated().Hour()

View file

@ -8,6 +8,7 @@ import (
"strings"
"github.com/photoprism/photoprism/internal/models"
"github.com/photoprism/photoprism/internal/util"
"github.com/pkg/errors"
)
@ -90,12 +91,11 @@ func (m *MediaFile) Location() (*models.Location, error) {
location.LocLong = lon
}
locationName := strings.ReplaceAll(openstreetmapLocation.Name, " - ", " / ")
locationName = strings.Title(strings.TrimSpace(strings.ReplaceAll(locationName, "_", " ")))
if len(openstreetmapLocation.Name) > 1 {
location.LocName = strings.ReplaceAll(openstreetmapLocation.Name, " - ", " / ")
location.LocName = util.Title(strings.TrimSpace(strings.ReplaceAll(location.LocName, "_", " ")))
}
locationCategory := strings.TrimSpace(strings.ReplaceAll(openstreetmapLocation.Category, "_", " "))
location.LocName = locationName
location.LocHouseNr = strings.TrimSpace(openstreetmapLocation.Address.HouseNumber)
location.LocStreet = strings.TrimSpace(openstreetmapLocation.Address.Road)
location.LocSuburb = strings.TrimSpace(openstreetmapLocation.Address.Suburb)
@ -105,6 +105,8 @@ func (m *MediaFile) Location() (*models.Location, error) {
location.LocCountry = strings.TrimSpace(openstreetmapLocation.Address.Country)
location.LocCountryCode = strings.TrimSpace(openstreetmapLocation.Address.CountryCode)
location.LocDisplayName = strings.TrimSpace(openstreetmapLocation.DisplayName)
locationCategory := strings.TrimSpace(strings.ReplaceAll(openstreetmapLocation.Category, "_", " "))
location.LocCategory = locationCategory
if openstreetmapLocation.Type != "yes" && openstreetmapLocation.Type != "unclassified" {

View file

@ -29,6 +29,8 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.LikeLabel(v1, conf)
api.DislikeLabel(v1, conf)
api.LabelThumbnail(v1, conf)
api.Upload(v1, conf)
}
// Default HTML page (client-side routing implemented via Vue.js)

View file

@ -1,12 +1,51 @@
package util
import (
"strings"
"unicode"
)
// isSeparator reports whether the rune could mark a word boundary.
func isSeparator(r rune) bool {
// ASCII alphanumerics and underscore are not separators
if r <= 0x7F {
switch {
case '0' <= r && r <= '9':
return false
case 'a' <= r && r <= 'z':
return false
case 'A' <= r && r <= 'Z':
return false
case r == '_', r == '\'':
return false
}
return true
}
// Letters and digits are not separators
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
// Otherwise, all we can do for now is treat spaces as separators.
return unicode.IsSpace(r)
}
func UcFirst(str string) string {
for i, v := range str {
return string(unicode.ToUpper(v)) + str[i+1:]
}
return ""
}
func Title(s string) string {
prev := ' '
return strings.Map(
func(r rune) rune {
if isSeparator(prev) {
prev = r
return unicode.ToTitle(r)
}
prev = r
return r
},
s)
}