Refactored tag search and added tag_slug column; improved search form

This commit is contained in:
Michael Mayer 2018-09-13 07:05:13 +02:00
parent 801b680f12
commit 91411a450b
18 changed files with 331 additions and 167 deletions

View file

@ -6,6 +6,6 @@ import (
type Album struct {
gorm.Model
AlbumName string
Photos []Photo `gorm:"many2many:album_photos;"`
AlbumName string
Photos []Photo `gorm:"many2many:album_photos;"`
}

View file

@ -7,8 +7,8 @@ import (
type Camera struct {
gorm.Model
CameraModel string
CameraType string
CameraNotes string
CameraType string
CameraNotes string
}
func NewCamera(modelName string) *Camera {
@ -27,4 +27,4 @@ func (c *Camera) FirstOrCreate(db *gorm.DB) *Camera {
db.FirstOrCreate(c, "camera_model = ?", c.CameraModel)
return c
}
}

View file

@ -147,6 +147,6 @@ func (c *Config) MigrateDb() {
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{}, &Location{}, &Camera{})
if !db.Dialect().HasIndex("photos", "photos_fulltext") {
db.Exec("CREATE FULLTEXT INDEX photos_fulltext ON photos (photo_title, photo_description, photo_artist, photo_keywords, photo_colors)")
db.Exec("CREATE FULLTEXT INDEX photos_fulltext ON photos (photo_title, photo_description, photo_artist, photo_colors)")
}
}

View file

@ -104,6 +104,14 @@ main div.page-export {
main div.page-settings {
}
.photo-tile {
cursor: pointer;
}
.photo-tile.selected {
opacity: 0.6;
}
.photo-grid .photo {
background: #eeeeee;
display: block;

View file

@ -24,10 +24,10 @@ Vue.use(Vuetify, {
primary: '#FDD835',
secondary: '#b0bec5',
accent: '#8c9eff',
error: '#b71c1c',
info: '#2196F3',
success: '#4CAF50',
warning: '#FFC107',
error: '#F44336',
info: '#00B8D4',
success: '#00BFA5',
warning: '#FFD600',
},
});

View file

@ -58,7 +58,7 @@
label="Sort By"
flat solo
color="blue-grey"
v-model="dir"
v-model="query.order"
:items="options.sorting">
</v-select>
</v-flex>
@ -84,6 +84,14 @@
>
<v-icon>menu</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="deep-purple lighten-2"
>
<v-icon>favorite</v-icon>
</v-btn>
<v-btn
fab
dark
@ -118,36 +126,54 @@
<v-icon>delete</v-icon>
</v-btn>
</v-speed-dial>
<v-container grid-list-sm fluid class="pa-0">
<v-container grid-list-xs fluid class="pa-0">
<v-layout row wrap>
<v-flex
v-for="photo in results"
:key="photo.ID"
xs2
d-flex
xs12 sm6 md3 lg2 d-flex
v-bind:class="{ selected: photo.selected }"
class="photo-tile"
>
<v-card-actions flat tile class="d-flex" @click="selectPhoto(photo)">
<v-img :src="'/api/v1/files/' + photo.FileID + '/square_thumbnail?size=500'"
aspect-ratio="1"
:title="photo.TakenAt | moment('DD.MM.YYYY hh:mm:ss')"
class="grey lighten-2"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
<v-tooltip bottom>
<v-card-actions flat tile class="d-flex" slot="activator" @click="selectPhoto(photo)"
@mouseover="overPhoto(photo)" @mouseleave="leavePhoto(photo)">
<v-img :src="'/api/v1/files/' + photo.FileID + '/square_thumbnail?size=500'"
aspect-ratio="1"
class="grey lighten-2"
>
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
</v-layout>
</v-img>
</v-card-actions>
<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-actions>
<span>{{ photo.PhotoTitle }}<br/>{{ photo.TakenAt | moment('DD/MM/YYYY') }} / {{ photo.CameraModel }}</span>
</v-tooltip>
</v-flex>
</v-layout>
</v-container>
<div style="clear: both"></div>
<v-snackbar
v-model="snackbarVisible"
bottom
:timeout="0"
>
{{ snackbarText }}
<v-btn
class="pr-0"
color="primary"
icon
flat
@click="clearSelection()"
>
<v-icon>close</v-icon>
</v-btn>
</v-snackbar>
</v-container>
</div>
</template>
@ -164,28 +190,46 @@
const resultCount = query.hasOwnProperty('count') ? parseInt(query['count']) : 70;
const resultPage = query.hasOwnProperty('page') ? parseInt(query['page']) : 1;
const resultOffset = resultCount * (resultPage - 1);
const order = query.hasOwnProperty('order') ? query['order'] : 'taken_at';
const order = query.hasOwnProperty('order') && query['order'] != "" ? query['order'] : 'taken_at DESC';
const dir = query.hasOwnProperty('dir') ? query['dir'] : '';
const q = query.hasOwnProperty('q') ? query['q'] : '';
const view = query.hasOwnProperty('view') ? query['view'] : 'tile';
return {
'snackbarVisible': false,
'snackbarText': '',
'advandedSearch': false,
'results': [],
'query': {
category: '',
country: '',
camera: '',
after: '',
before: '',
favorites_only: '',
order: order,
q: q,
},
'options': {
'categories': [ { value: '', text: 'All Categories' }, { value: 'junction', text: 'Junction' }, { value: 'tourism', text: 'Tourism'}, { value: 'historic', text: 'Historic'} ],
'countries': [{ value: '', text: 'All Countries' }, { value: 'de', text: 'Germany' }, { value: 'ca', text: 'Canada'}, { value: 'us', text: 'United States'}],
'cameras': [{ value: '', text: 'All Cameras' }, { value: '1', text: 'iPhone SE' }, { value: '2', text: 'Canon EOS 6D'}],
'sorting': [{ value: '', text: 'Sort by date taken' }, { value: 'imported', text: 'Sort by date imported'}, { value: 'score', text: 'Sort by relevance' }],
'categories': [
{value: '', text: 'All Categories'},
{value: 'junction', text: 'Junction'},
{value: 'tourism', text: 'Tourism'},
{value: 'historic', text: 'Historic'},
],
'countries': [
{value: '', text: 'All Countries'},
{value: 'de', text: 'Germany'},
{value: 'ca', text: 'Canada'},
{value: 'us', text: 'United States'}
],
'cameras': [
{value: '', text: 'All Cameras'},
{value: '1', text: 'iPhone SE'},
{value: '2', text: 'Canon EOS 6D'},
],
'sorting': [
{value: 'taken_at DESC', text: 'Newest first'},
{value: 'taken_at', text: 'Oldest first'},
{value: 'created_at DESC', text: 'Recently imported'},
],
},
'page': resultPage,
'order': order,
@ -196,11 +240,51 @@
'resultTotal': 'Many',
'lastQuery': {},
'submitTimeout': false,
'selected': []
};
},
methods: {
overPhoto(photo) {
},
leavePhoto(photo) {
},
clearSelection() {
for (let i = 0; i < this.selected.length; i++) {
this.selected[i].selected = false;
}
this.selected = [];
this.snackbarText = '';
this.snackbarVisible = false;
},
selectPhoto(photo) {
this.$alert.success(photo.getEntityName());
console.log(photo)
if (photo.selected) {
for (let i = 0; i < this.selected.length; i++) {
if (this.selected[i].id === photo.id) {
this.selected.splice(i, 1)
break;
}
}
photo.selected = false;
} else {
this.selected.push(photo);
photo.selected = true;
}
if (this.selected.length > 0) {
if (this.selected.length === 1) {
this.snackbarText = 'One photo selected';
} else {
this.snackbarText = this.selected.length + ' photos selected';
}
this.snackbarVisible = true;
} else {
this.snackbarText = '';
this.snackbarVisible = false;
}
},
likePhoto(photo) {
photo.Favorite = !photo.Favorite;
@ -220,7 +304,6 @@
const params = {
count: this.resultCount,
offset: this.resultCount * (this.page - 1),
order: this.order !== '' ? this.order + ' ' + this.dir : '',
};
Object.assign(params, this.query);
@ -234,8 +317,6 @@
const urlParams = {
count: this.resultCount,
page: this.page,
order: this.order,
dir: this.dir,
};
Object.assign(urlParams, this.query);

View file

@ -10,6 +10,7 @@ export default [
{ name: 'photos', path: '/photos', component: Photos },
{ name: 'filters', path: '/filters', component: Todo },
{ name: 'calendar', path: '/calendar', component: Todo },
{ name: 'tags', path: '/tags', component: Todo },
{ name: 'bookmarks', path: '/bookmarks', component: Todo },
{ name: 'favorites', path: '/favorites', component: Todo },
{ name: 'places', path: '/places', component: Todo },

View file

@ -1,12 +1,14 @@
<template>
<v-snackbar
v-model="visible"
bottom
:color="color"
top
right
>
{{ text }}
<v-btn
class="pr-0"
color="primary"
icon
flat
@click="close"
@ -23,6 +25,7 @@
data() {
return {
text: '',
color: 'primary',
visible: false,
messages: [],
lastMessageId: 1,
@ -74,13 +77,13 @@
this.addMessage('info', message, 3000);
},
addMessage: function (type, message, delay) {
addMessage: function (color, message, delay) {
if (message === this.lastMessage) return;
this.lastMessageId++;
this.lastMessage = message;
const alert = {'id': this.lastMessageId, 'type': type, 'delay': delay, 'msg': message};
const alert = {'id': this.lastMessageId, 'color': color, 'delay': delay, 'msg': message};
this.messages.push(alert);
@ -98,6 +101,7 @@
if(message) {
this.text = message.msg;
this.color = message.color;
this.visible = true;
setTimeout(() => {

View file

@ -65,6 +65,16 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/tags" @click="">
<v-list-tile-action>
<v-icon>label</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>Tags</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/favorites" @click="">
<v-list-tile-action>
<v-icon>favorite</v-icon>

2
go.mod
View file

@ -14,6 +14,7 @@ require (
github.com/gin-gonic/gin v1.3.0
github.com/go-sql-driver/mysql v1.4.0
github.com/golang/protobuf v1.2.0
github.com/gosimple/slug v1.2.0
github.com/jinzhu/gorm v1.9.1
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a
github.com/json-iterator/go v1.1.5
@ -26,6 +27,7 @@ require (
github.com/modern-go/reflect2 v1.0.1
github.com/pkg/errors v0.8.0
github.com/pmezard/go-difflib v1.0.0
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/rwcarlsen/goexif v0.0.0-20180518182100-8d986c03457a
github.com/steakknife/hamming v0.0.0-20180906055917-c99c65617cd3
github.com/stretchr/testify v1.2.2

4
go.sum
View file

@ -34,6 +34,8 @@ github.com/golang/protobuf v1.1.0 h1:0iH4Ffd/meGoXqF2lSAhZHt8X+cPgkfn/cb6Cce5Vpc
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gosimple/slug v1.2.0 h1:DqQXHQLprYBsiO4ZtdadqBeKh7CFnl5qoVNkKkVI7No=
github.com/gosimple/slug v1.2.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
github.com/jinzhu/gorm v1.9.1 h1:lDSDtsCt5AGGSKTs8AHlSDbbgif4G4+CKJ8ETBDVHTA=
github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k=
@ -60,6 +62,8 @@ github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rwcarlsen/goexif v0.0.0-20180518182100-8d986c03457a h1:ZDZdsnbMuRSoVbq1gR47o005lfn2OwODNCr23zh9gSk=
github.com/rwcarlsen/goexif v0.0.0-20180518182100-8d986c03457a/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/steakknife/hamming v0.0.0-20161012051909-5ac3f73b8842 h1:Xk8V2cXXyb8xE/JOy2d8+0byqbKS1pEhVaeENndbaME=

View file

@ -26,28 +26,22 @@ func NewIndexer(originalsPath string, db *gorm.DB) *Indexer {
return instance
}
func (i *Indexer) GetImageTags(jpeg *MediaFile) (result []Tag) {
func (i *Indexer) GetImageTags(jpeg *MediaFile) (results []*Tag) {
if imageBuffer, err := ioutil.ReadFile(jpeg.filename); err == nil {
tags, err := recognize.GetImageTags(string(imageBuffer))
if err != nil {
return result
return results
}
for _, tag := range tags {
if tag.Probability > 0.2 { // TODO: Use config variable
var tagModel Tag
if res := i.db.First(&tagModel, "tag_label = ?", tag.Label); res.Error != nil {
tagModel.TagLabel = tag.Label
}
result = append(result, tagModel)
results = i.appendTag(results, tag.Label)
}
}
}
return result
return results
}
func getKeywordWithSynonyms(keyword string) []string {
@ -81,12 +75,30 @@ func getKeywordsAsString(keywords []string) string {
return strings.ToLower(strings.Join(result, ", "))
}
func (i *Indexer) appendTag(tags []*Tag, label string) []*Tag {
if label == "" {
return tags
}
label = strings.ToLower(label)
for _, tag := range tags {
if tag.TagLabel == label {
return tags
}
}
tag := NewTag(label).FirstOrCreate(i.db)
return append(tags, tag)
}
func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
var photo Photo
var file, primaryFile File
var isPrimary = false
var colorNames []string
var keywords []string
var tags []*Tag
canonicalName := mediaFile.GetCanonicalNameFromFile()
fileHash := mediaFile.GetHash()
@ -111,17 +123,19 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
photo.PhotoColors = strings.Join(colorNames, ", ")
// Tags (TensorFlow)
photo.Tags = i.GetImageTags(jpeg)
for _, tag := range photo.Tags {
keywords = append(keywords, tag.TagLabel)
}
tags = i.GetImageTags(jpeg)
}
if location, err := mediaFile.GetLocation(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
keywords = append(keywords, location.LocCity, location.LocCounty, location.LocCountry, location.LocCategory, location.LocName, location.LocType)
tags = i.appendTag(tags, location.LocCity)
tags = i.appendTag(tags, location.LocCounty)
tags = i.appendTag(tags, location.LocCountry)
tags = i.appendTag(tags, location.LocCategory)
tags = i.appendTag(tags, location.LocName)
tags = i.appendTag(tags, location.LocType)
if location.LocName != "" { // TODO: User defined title format
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocName, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
@ -140,12 +154,10 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
}
}
photo.PhotoKeywords = getKeywordsAsString(keywords)
photo.Tags = tags
photo.Camera = NewCamera(mediaFile.GetCameraModel()).FirstOrCreate(i.db)
photo.TakenAt = mediaFile.GetDateCreated()
photo.PhotoCanonicalName = canonicalName
photo.Files = []File{}
photo.Albums = []Album{}
photo.PhotoFavorite = false

View file

@ -11,7 +11,6 @@ type Photo struct {
PhotoTitle string
PhotoDescription string `gorm:"type:text;"`
PhotoArtist string
PhotoKeywords string
PhotoColors string
PhotoVibrantColor string
PhotoMutedColor string
@ -22,9 +21,9 @@ type Photo struct {
PhotoLong float64
Location *Location
LocationID uint
Tags []Tag `gorm:"many2many:photo_tags;"`
Files []File
Albums []Album `gorm:"many2many:album_photos;"`
Tags []*Tag `gorm:"many2many:photo_tags;"`
Files []*File
Albums []*Album `gorm:"many2many:album_photos;"`
Camera *Camera
CameraID uint
}

View file

@ -3,6 +3,7 @@ package photoprism
import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/forms"
"strings"
"time"
)
@ -57,9 +58,12 @@ type PhotoSearchResult struct {
FileHeight int
FileOrientation int
FileAspectRatio float64
// Tags
Tags string
}
func NewQuery(originalsPath string, db *gorm.DB) *Search {
func NewSearch(originalsPath string, db *gorm.DB) *Search {
instance := &Search{
originalsPath: originalsPath,
db: db,
@ -75,17 +79,21 @@ func (s *Search) Photos(form forms.PhotoSearchForm) ([]PhotoSearchResult, error)
Select(`photos.*,
files.id AS file_id, files.file_name, files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio, files.file_orientation,
cameras.camera_model,
locations.loc_display_name, locations.loc_name, locations.loc_city, locations.loc_postcode, locations.loc_country, locations.loc_country_code, locations.loc_category, locations.loc_type`).
locations.loc_display_name, locations.loc_name, locations.loc_city, locations.loc_postcode, locations.loc_country, locations.loc_country_code, locations.loc_category, locations.loc_type,
GROUP_CONCAT(tags.tag_label) AS tags`).
Joins("JOIN files ON files.photo_id = photos.id AND files.file_primary AND files.deleted_at IS NULL").
Joins("JOIN cameras ON cameras.id = photos.camera_id").
Joins("LEFT JOIN locations ON locations.id = photos.location_id").
Where("photos.deleted_at IS NULL")
Joins("LEFT JOIN photo_tags ON photo_tags.photo_id = photos.id").
Joins("LEFT JOIN tags ON photo_tags.tag_id = tags.id").
Where("photos.deleted_at IS NULL").
Group("photos.id, files.id")
if form.Query != "" {
q = q.Where("MATCH (photo_title, photo_description, photo_artist, photo_keywords, photo_colors) AGAINST (? IN BOOLEAN MODE)", form.Query)
q = q.Where("tags.tag_label LIKE ? OR MATCH (photo_title, photo_description, photo_artist, photo_colors) AGAINST (?)", strings.ToLower(form.Query)+"%", form.Query)
}
q = q.Order("taken_at").Limit(form.Count).Offset(form.Offset)
q = q.Order(form.Order).Limit(form.Count).Offset(form.Offset)
results := make([]PhotoSearchResult, 0, form.Count)

View file

@ -12,7 +12,7 @@ func TestSearch_Photos(t *testing.T) {
conf.InitializeTestData(t)
search := NewQuery(conf.OriginalsPath, conf.GetDb())
search := NewSearch(conf.OriginalsPath, conf.GetDb())
var form forms.PhotoSearchForm

95
server/routes.go Normal file
View file

@ -0,0 +1,95 @@
package server
import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism"
"github.com/photoprism/photoprism/forms"
"net/http"
"strconv"
)
func ConfigureRoutes(app *gin.Engine, conf *photoprism.Config) {
app.LoadHTMLGlob("server/templates/*")
app.StaticFile("/favicon.ico", "./server/assets/favicon.ico")
app.StaticFile("/robots.txt", "./server/assets/robots.txt")
app.Static("/assets", "./server/assets")
// JSON-REST API Version 1
v1 := app.Group("/api/v1")
{
v1.GET("/photos", func(c *gin.Context) {
var form forms.PhotoSearchForm
search := photoprism.NewSearch(conf.OriginalsPath, conf.GetDb())
c.MustBindWith(&form, binding.Form)
if photos, err := search.Photos(form); err == nil {
c.Header("x-result-total", strconv.Itoa(len(photos)))
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, photos)
} else {
c.AbortWithError(400, err)
}
})
// v1.OPTIONS()
v1.GET("/files", func(c *gin.Context) {
search := photoprism.NewSearch(conf.OriginalsPath, conf.GetDb())
files := search.FindFiles(70, 0)
c.JSON(http.StatusOK, files)
})
v1.GET("/files/:id/thumbnail", func(c *gin.Context) {
id := c.Param("id")
size, _ := strconv.Atoi(c.Query("size"))
search := photoprism.NewSearch(conf.OriginalsPath, conf.GetDb())
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetThumbnail(conf.ThumbnailsPath, size)
c.File(thumbnail.GetFilename())
})
v1.GET("/files/:id/square_thumbnail", func(c *gin.Context) {
id := c.Param("id")
size, _ := strconv.Atoi(c.Query("size"))
search := photoprism.NewSearch(conf.OriginalsPath, conf.GetDb())
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetSquareThumbnail(conf.ThumbnailsPath, size)
c.File(thumbnail.GetFilename())
})
v1.GET("/albums", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1.GET("/tags", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
app.NoRoute(func(c *gin.Context) {
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "PhotoPrism",
"debug": true,
})
})
}

View file

@ -3,99 +3,13 @@ package server
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism"
"github.com/photoprism/photoprism/forms"
"net/http"
"strconv"
)
func Start(address string, port int, conf *photoprism.Config) {
router := gin.Default()
app := gin.Default()
router.LoadHTMLGlob("server/templates/*")
ConfigureRoutes(app, conf)
router.StaticFile("/favicon.ico", "./server/assets/favicon.ico")
router.StaticFile("/robots.txt", "./server/assets/robots.txt")
// JSON-REST API Version 1
v1 := router.Group("/api/v1")
{
v1.GET("/photos", func(c *gin.Context) {
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())
var form forms.PhotoSearchForm
c.MustBindWith(&form, binding.Form)
if photos, err := search.Photos(form); err == nil {
c.Header("x-result-total", strconv.Itoa(len(photos)))
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, photos)
} else {
c.AbortWithError(500, err)
}
})
// v1.OPTIONS()
v1.GET("/files", func(c *gin.Context) {
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())
files := search.FindFiles(70, 0)
c.JSON(http.StatusOK, files)
})
v1.GET("/files/:id/thumbnail", func(c *gin.Context) {
id := c.Param("id")
size, _ := strconv.Atoi(c.Query("size"))
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetThumbnail(conf.ThumbnailsPath, size)
c.File(thumbnail.GetFilename())
})
v1.GET("/files/:id/square_thumbnail", func(c *gin.Context) {
id := c.Param("id")
size, _ := strconv.Atoi(c.Query("size"))
search := photoprism.NewQuery(conf.OriginalsPath, conf.GetDb())
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetSquareThumbnail(conf.ThumbnailsPath, size)
c.File(thumbnail.GetFilename())
})
v1.GET("/albums", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1.GET("/tags", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
}
router.Static("/assets", "./server/assets")
router.NoRoute(func(c *gin.Context) {
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "PhotoPrism",
"debug": true,
})
})
router.Run(fmt.Sprintf("%s:%d", address, port))
app.Run(fmt.Sprintf("%s:%d", address, port))
}

26
tag.go
View file

@ -1,10 +1,36 @@
package photoprism
import (
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"strings"
)
type Tag struct {
gorm.Model
TagLabel string `gorm:"type:varchar(100);unique_index"`
TagSlug string `gorm:"type:varchar(100);unique_index"`
}
func NewTag(label string) *Tag {
if label == "" {
label = "unknown"
}
tagLabel := strings.ToLower(label)
tagSlug := slug.MakeLang(tagLabel, "en")
result := &Tag{
TagLabel: tagLabel,
TagSlug: tagSlug,
}
return result
}
func (t *Tag) FirstOrCreate(db *gorm.DB) *Tag {
db.FirstOrCreate(t, "tag_label = ?", t.TagLabel)
return t
}