mirror of
https://github.com/photoprism/photoprism.git
synced 2025-10-06 01:07:16 +08:00
Backend: Improve resilience #1544
This commit is contained in:
64
docker-compose.db.yml
Normal file
64
docker-compose.db.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
version: '3.5'
|
||||||
|
|
||||||
|
# Legacy databases servers for testing.
|
||||||
|
services:
|
||||||
|
mariadb-10-3:
|
||||||
|
image: mariadb:10.3
|
||||||
|
container_name: mariadb-10-3
|
||||||
|
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
||||||
|
expose:
|
||||||
|
- "4001"
|
||||||
|
volumes:
|
||||||
|
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: photoprism
|
||||||
|
MYSQL_USER: photoprism
|
||||||
|
MYSQL_PASSWORD: photoprism
|
||||||
|
MYSQL_DATABASE: photoprism
|
||||||
|
|
||||||
|
mariadb-10-2:
|
||||||
|
image: mariadb:10.2
|
||||||
|
container_name: mariadb-10-2
|
||||||
|
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
||||||
|
expose:
|
||||||
|
- "4001"
|
||||||
|
volumes:
|
||||||
|
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: photoprism
|
||||||
|
MYSQL_USER: photoprism
|
||||||
|
MYSQL_PASSWORD: photoprism
|
||||||
|
MYSQL_DATABASE: photoprism
|
||||||
|
|
||||||
|
mariadb-10-1:
|
||||||
|
image: mariadb:10.1
|
||||||
|
container_name: mariadb-10-1
|
||||||
|
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
||||||
|
expose:
|
||||||
|
- "4001"
|
||||||
|
volumes:
|
||||||
|
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: photoprism
|
||||||
|
MYSQL_USER: photoprism
|
||||||
|
MYSQL_PASSWORD: photoprism
|
||||||
|
MYSQL_DATABASE: photoprism
|
||||||
|
|
||||||
|
mysql-8:
|
||||||
|
image: mysql:8
|
||||||
|
container_name: mysql-8
|
||||||
|
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
||||||
|
expose:
|
||||||
|
- "4001"
|
||||||
|
volumes:
|
||||||
|
- "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: photoprism
|
||||||
|
MYSQL_USER: photoprism
|
||||||
|
MYSQL_PASSWORD: photoprism
|
||||||
|
MYSQL_DATABASE: photoprism
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
external:
|
||||||
|
name: shared
|
59
docker-compose.latest.yml
Normal file
59
docker-compose.latest.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
version: '3.5'
|
||||||
|
|
||||||
|
# Latest stable version for testing.
|
||||||
|
services:
|
||||||
|
photoprism-latest:
|
||||||
|
image: photoprism/photoprism:latest
|
||||||
|
container_name: photoprism-latest
|
||||||
|
security_opt:
|
||||||
|
- seccomp:unconfined
|
||||||
|
- apparmor:unconfined
|
||||||
|
ports:
|
||||||
|
- "2344:2342" # [local port]:[container port]
|
||||||
|
environment:
|
||||||
|
UID: ${UID:-1000}
|
||||||
|
PHOTOPRISM_SITE_URL: "http://localhost:2344/"
|
||||||
|
PHOTOPRISM_SITE_TITLE: "PhotoPrism"
|
||||||
|
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
|
||||||
|
PHOTOPRISM_SITE_DESCRIPTION: "Open-Source Photo Management"
|
||||||
|
PHOTOPRISM_SITE_AUTHOR: "@photoprism_app"
|
||||||
|
PHOTOPRISM_DEBUG: "true"
|
||||||
|
PHOTOPRISM_READONLY: "false"
|
||||||
|
PHOTOPRISM_PUBLIC: "true"
|
||||||
|
PHOTOPRISM_EXPERIMENTAL: "false"
|
||||||
|
PHOTOPRISM_SERVER_MODE: "debug"
|
||||||
|
PHOTOPRISM_HTTP_HOST: "0.0.0.0"
|
||||||
|
PHOTOPRISM_HTTP_PORT: 2342
|
||||||
|
PHOTOPRISM_HTTP_COMPRESSION: "gzip" # Improves transfer speed and bandwidth utilization (none or gzip)
|
||||||
|
PHOTOPRISM_DATABASE_DRIVER: "mysql"
|
||||||
|
PHOTOPRISM_DATABASE_SERVER: "mariadb:4001"
|
||||||
|
PHOTOPRISM_DATABASE_NAME: "latest"
|
||||||
|
PHOTOPRISM_DATABASE_USER: "root"
|
||||||
|
PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
|
||||||
|
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
|
||||||
|
PHOTOPRISM_DISABLE_BACKUPS: "false" # Don't backup photo and album metadata to YAML files
|
||||||
|
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
|
||||||
|
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
|
||||||
|
PHOTOPRISM_DISABLE_PLACES: "false" # Disables reverse geocoding and maps
|
||||||
|
PHOTOPRISM_DISABLE_EXIFTOOL: "false" # Don't create ExifTool JSON files for improved metadata extraction
|
||||||
|
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Don't use TensorFlow for image classification
|
||||||
|
PHOTOPRISM_DETECT_NSFW: "false" # Flag photos as private that MAY be offensive (requires TensorFlow)
|
||||||
|
PHOTOPRISM_UPLOAD_NSFW: "false" # Allows uploads that may be offensive
|
||||||
|
PHOTOPRISM_DARKTABLE_PRESETS: "false" # Enables Darktable presets and disables concurrent RAW conversion
|
||||||
|
PHOTOPRISM_THUMB_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
|
||||||
|
PHOTOPRISM_THUMB_UNCACHED: "true" # Enables on-demand thumbnail rendering (high memory and cpu usage)
|
||||||
|
PHOTOPRISM_THUMB_SIZE: 2048 # Pre-rendered thumbnail size limit (default 2048, min 720, max 7680)
|
||||||
|
# PHOTOPRISM_THUMB_SIZE: 4096 # Retina 4K, DCI 4K (requires more storage); 7680 for 8K Ultra HD
|
||||||
|
PHOTOPRISM_THUMB_SIZE_UNCACHED: 7680 # On-demand rendering size limit (default 7680, min 720, max 7680)
|
||||||
|
PHOTOPRISM_JPEG_SIZE: 7680 # Size limit for converted image files in pixels (720-30000)
|
||||||
|
PHOTOPRISM_JPEG_QUALITY: 92 # Set to 95 for high-quality thumbnails (25-100)
|
||||||
|
TF_CPP_MIN_LOG_LEVEL: 0 # Show TensorFlow log messages for development
|
||||||
|
working_dir: "/photoprism"
|
||||||
|
volumes:
|
||||||
|
- "./storage/latest:/photoprism/storage"
|
||||||
|
- "./storage/originals:/photoprism/originals"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
external:
|
||||||
|
name: shared
|
20
docker-compose.proxy.yml
Normal file
20
docker-compose.proxy.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
version: '3.5'
|
||||||
|
|
||||||
|
# Reverse proxy servers for testing.
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
container_name: caddy
|
||||||
|
depends_on:
|
||||||
|
- photoprism
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./docker/development/caddy:/data/caddy/pki/authorities/local
|
||||||
|
- ./docker/development/caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
external:
|
||||||
|
name: shared
|
@@ -4,6 +4,7 @@ services:
|
|||||||
photoprism:
|
photoprism:
|
||||||
build: .
|
build: .
|
||||||
image: photoprism/photoprism:develop
|
image: photoprism/photoprism:develop
|
||||||
|
container_name: photoprism
|
||||||
depends_on:
|
depends_on:
|
||||||
- mariadb
|
- mariadb
|
||||||
- webdav-dummy
|
- webdav-dummy
|
||||||
@@ -65,6 +66,7 @@ services:
|
|||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:10.5
|
image: mariadb:10.5
|
||||||
|
container_name: mariadb
|
||||||
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
||||||
expose:
|
expose:
|
||||||
- "4001"
|
- "4001"
|
||||||
@@ -81,34 +83,11 @@ services:
|
|||||||
webdav-dummy:
|
webdav-dummy:
|
||||||
image: photoprism/webdav:20210602
|
image: photoprism/webdav:20210602
|
||||||
|
|
||||||
# Uncomment to test with MySQL 8:
|
|
||||||
#
|
|
||||||
# mysql:
|
|
||||||
# image: mysql:8
|
|
||||||
# command: mysqld --port=4001 --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=50
|
|
||||||
# expose:
|
|
||||||
# - "4001"
|
|
||||||
# volumes:
|
|
||||||
# - "./scripts/sql/init-test-databases.sql:/docker-entrypoint-initdb.d/init-test-databases.sql"
|
|
||||||
# environment:
|
|
||||||
# MYSQL_ROOT_PASSWORD: photoprism
|
|
||||||
# MYSQL_USER: photoprism
|
|
||||||
# MYSQL_PASSWORD: photoprism
|
|
||||||
# MYSQL_DATABASE: photoprism
|
|
||||||
|
|
||||||
# Uncomment to test with Caddy as reverse proxy:
|
|
||||||
#
|
|
||||||
# caddy:
|
|
||||||
# image: caddy:2
|
|
||||||
# depends_on:
|
|
||||||
# - photoprism
|
|
||||||
# ports:
|
|
||||||
# - "80:80"
|
|
||||||
# - "443:443"
|
|
||||||
# volumes:
|
|
||||||
# - ./docker/development/caddy:/data/caddy/pki/authorities/local
|
|
||||||
# - ./docker/development/caddy/Caddyfile:/etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
go-mod:
|
go-mod:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: shared
|
||||||
|
driver: bridge
|
||||||
|
@@ -188,7 +188,7 @@ export class Rest extends Model {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return Api.get(this.getCollectionResource(), options).then((resp) => {
|
return Api.get(this.getCollectionResource(), options).then((resp) => {
|
||||||
let count = resp.data.length;
|
let count = resp.data ? resp.data.length : 0;
|
||||||
let limit = 0;
|
let limit = 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
@@ -211,8 +211,10 @@ export class Rest extends Model {
|
|||||||
resp.limit = limit;
|
resp.limit = limit;
|
||||||
resp.offset = offset;
|
resp.offset = offset;
|
||||||
|
|
||||||
for (let i = 0; i < resp.data.length; i++) {
|
if (count > 0) {
|
||||||
resp.models.push(new this(resp.data[i]));
|
for (let i = 0; i < resp.data.length; i++) {
|
||||||
|
resp.models.push(new this(resp.data[i]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve(resp);
|
return Promise.resolve(resp);
|
||||||
|
@@ -47,7 +47,7 @@ func RemoveFromFolderCache(rootName string) {
|
|||||||
cache.Delete(cacheKey)
|
cache.Delete(cacheKey)
|
||||||
|
|
||||||
if err := query.UpdateAlbumFolderPreviews(); err != nil {
|
if err := query.UpdateAlbumFolderPreviews(); err != nil {
|
||||||
log.Errorf("failed updating folder previews: %s", err)
|
log.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("removed %s from cache", cacheKey)
|
log.Debugf("removed %s from cache", cacheKey)
|
||||||
@@ -66,7 +66,7 @@ func RemoveFromAlbumCoverCache(uid string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := query.UpdateAlbumPreviews(); err != nil {
|
if err := query.UpdateAlbumPreviews(); err != nil {
|
||||||
log.Errorf("failed updating album previews: %s", err)
|
log.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func FlushCoverCache() {
|
|||||||
service.CoverCache().Flush()
|
service.CoverCache().Flush()
|
||||||
|
|
||||||
if err := query.UpdatePreviews(); err != nil {
|
if err := query.UpdatePreviews(); err != nil {
|
||||||
log.Errorf("failed updating preview images: %s", err)
|
log.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("albums: flushed cover cache")
|
log.Debugf("albums: flushed cover cache")
|
||||||
|
@@ -99,7 +99,7 @@ func UpdateSubject(router *gin.RouterGroup) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if txt.NameSlug(f.SubjName) == "" {
|
if txt.Slug(f.SubjName) == "" {
|
||||||
// Return unchanged model data if (normalized) name is empty.
|
// Return unchanged model data if (normalized) name is empty.
|
||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
return
|
return
|
||||||
|
@@ -86,7 +86,7 @@ func main() {
|
|||||||
var packageTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT.
|
var packageTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT.
|
||||||
package classify
|
package classify
|
||||||
|
|
||||||
var rules = LabelRules{
|
var Rules = LabelRules{
|
||||||
{{- range $key, $value := .Rules }}
|
{{- range $key, $value := .Rules }}
|
||||||
{{ printf "%q" $key }}: {
|
{{ printf "%q" $key }}: {
|
||||||
Label: {{ printf "%q" $value.Label }},
|
Label: {{ printf "%q" $value.Label }},
|
||||||
|
@@ -3,8 +3,6 @@ package classify
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/face"
|
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/pkg/txt"
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ func LocationLabel(name string, uncertainty int) Label {
|
|||||||
|
|
||||||
var categories []string
|
var categories []string
|
||||||
|
|
||||||
if rule, ok := rules.Find(name); ok {
|
if rule, ok := Rules.Find(name); ok {
|
||||||
priority = rule.Priority
|
priority = rule.Priority
|
||||||
categories = rule.Categories
|
categories = rule.Categories
|
||||||
}
|
}
|
||||||
@@ -49,26 +47,3 @@ func LocationLabel(name string, uncertainty int) Label {
|
|||||||
func (l Label) Title() string {
|
func (l Label) Title() string {
|
||||||
return txt.Title(txt.Clip(l.Name, txt.ClipDefault))
|
return txt.Title(txt.Clip(l.Name, txt.ClipDefault))
|
||||||
}
|
}
|
||||||
|
|
||||||
// FaceLabels returns matching labels if there are people in the image.
|
|
||||||
func FaceLabels(faces face.Faces, src string) Labels {
|
|
||||||
var r LabelRule
|
|
||||||
|
|
||||||
count := faces.Count()
|
|
||||||
|
|
||||||
if count < 1 {
|
|
||||||
return Labels{}
|
|
||||||
} else if count == 1 {
|
|
||||||
r = rules["portrait"]
|
|
||||||
} else {
|
|
||||||
r = rules["people"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return Labels{Label{
|
|
||||||
Name: r.Label,
|
|
||||||
Source: src,
|
|
||||||
Uncertainty: faces.Uncertainty(),
|
|
||||||
Priority: r.Priority,
|
|
||||||
Categories: r.Categories,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
@@ -3,8 +3,6 @@ package classify
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/face"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,50 +51,3 @@ func TestLabel_Title(t *testing.T) {
|
|||||||
assert.Equal(t, "Berlin / Neukölln Hasenheide", LocLabel.Title())
|
assert.Equal(t, "Berlin / Neukölln Hasenheide", LocLabel.Title())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFaceLabels(t *testing.T) {
|
|
||||||
Face1 := face.Face{
|
|
||||||
Rows: 0,
|
|
||||||
Cols: 0,
|
|
||||||
Score: 0,
|
|
||||||
Area: face.Area{},
|
|
||||||
Eyes: nil,
|
|
||||||
Landmarks: nil,
|
|
||||||
Embeddings: nil,
|
|
||||||
}
|
|
||||||
Face2 := face.Face{
|
|
||||||
Rows: 0,
|
|
||||||
Cols: 0,
|
|
||||||
Score: 0,
|
|
||||||
Area: face.Area{},
|
|
||||||
Eyes: nil,
|
|
||||||
Landmarks: nil,
|
|
||||||
Embeddings: nil,
|
|
||||||
}
|
|
||||||
t.Run("count < 1", func(t *testing.T) {
|
|
||||||
Faces := face.Faces{}
|
|
||||||
FaceLabels := FaceLabels(Faces, "")
|
|
||||||
t.Log(FaceLabels)
|
|
||||||
assert.Equal(t, 0, FaceLabels.Len())
|
|
||||||
})
|
|
||||||
t.Run("count > 1", func(t *testing.T) {
|
|
||||||
Faces := face.Faces{Face1, Face2}
|
|
||||||
FaceLabels := FaceLabels(Faces, "")
|
|
||||||
t.Log(FaceLabels)
|
|
||||||
assert.Equal(t, "people", FaceLabels[0].Name)
|
|
||||||
assert.Equal(t, "", FaceLabels[0].Source)
|
|
||||||
assert.Equal(t, 50, FaceLabels[0].Uncertainty)
|
|
||||||
assert.Equal(t, 0, FaceLabels[0].Priority)
|
|
||||||
//assert.Equal(t, "", FaceLabels[0].Categories)
|
|
||||||
})
|
|
||||||
t.Run("count = 1", func(t *testing.T) {
|
|
||||||
Faces := face.Faces{Face1}
|
|
||||||
FaceLabels := FaceLabels(Faces, "test")
|
|
||||||
t.Log(FaceLabels)
|
|
||||||
assert.Equal(t, "portrait", FaceLabels[0].Name)
|
|
||||||
assert.Equal(t, "test", FaceLabels[0].Source)
|
|
||||||
assert.Equal(t, 50, FaceLabels[0].Uncertainty)
|
|
||||||
assert.Equal(t, 0, FaceLabels[0].Priority)
|
|
||||||
assert.Equal(t, "people", FaceLabels[0].Categories[0])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by go generate; DO NOT EDIT.
|
// Code generated by go generate; DO NOT EDIT.
|
||||||
package classify
|
package classify
|
||||||
|
|
||||||
var rules = LabelRules{
|
var Rules = LabelRules{
|
||||||
"abacus": {
|
"abacus": {
|
||||||
Label: "",
|
Label: "",
|
||||||
Threshold: 1.000000,
|
Threshold: 1.000000,
|
||||||
|
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLabelRules_Find(t *testing.T) {
|
func TestLabelRules_Find(t *testing.T) {
|
||||||
result, ok := rules.Find("cat")
|
result, ok := Rules.Find("cat")
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "cat", result.Label)
|
assert.Equal(t, "cat", result.Label)
|
||||||
assert.Equal(t, "animal", result.Categories[0])
|
assert.Equal(t, "animal", result.Categories[0])
|
||||||
|
@@ -180,7 +180,7 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
|
|||||||
|
|
||||||
labelText := strings.ToLower(t.labels[i])
|
labelText := strings.ToLower(t.labels[i])
|
||||||
|
|
||||||
rule, _ := rules.Find(labelText)
|
rule, _ := Rules.Find(labelText)
|
||||||
|
|
||||||
// discard labels that don't met the threshold
|
// discard labels that don't met the threshold
|
||||||
if p < rule.Threshold {
|
if p < rule.Threshold {
|
||||||
|
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/photoprism/photoprism/internal/remote"
|
"github.com/photoprism/photoprism/internal/remote"
|
||||||
"github.com/photoprism/photoprism/internal/remote/webdav"
|
"github.com/photoprism/photoprism/internal/remote/webdav"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
"github.com/ulule/deepcopier"
|
"github.com/ulule/deepcopier"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ type Accounts []Account
|
|||||||
// Account represents a remote service account for uploading, downloading or syncing media files.
|
// Account represents a remote service account for uploading, downloading or syncing media files.
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID uint `gorm:"primary_key"`
|
ID uint `gorm:"primary_key"`
|
||||||
AccName string `gorm:"type:VARCHAR(255);"`
|
AccName string `gorm:"type:VARCHAR(160);"`
|
||||||
AccOwner string `gorm:"type:VARCHAR(255);"`
|
AccOwner string `gorm:"type:VARCHAR(160);"`
|
||||||
AccURL string `gorm:"type:VARBINARY(512);"`
|
AccURL string `gorm:"type:VARBINARY(512);"`
|
||||||
AccType string `gorm:"type:VARBINARY(255);"`
|
AccType string `gorm:"type:VARBINARY(255);"`
|
||||||
AccKey string `gorm:"type:VARBINARY(255);"`
|
AccKey string `gorm:"type:VARBINARY(255);"`
|
||||||
@@ -66,7 +67,7 @@ func CreateAccount(form form.Account) (model *Account, err error) {
|
|||||||
return model, err
|
return model, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves the entity using form data and stores it in the database.
|
// SaveForm saves the entity using form data and stores it in the database.
|
||||||
func (m *Account) SaveForm(form form.Account) error {
|
func (m *Account) SaveForm(form form.Account) error {
|
||||||
db := Db()
|
db := Db()
|
||||||
|
|
||||||
@@ -94,6 +95,9 @@ func (m *Account) SaveForm(form form.Account) error {
|
|||||||
m.SyncStatus = AccountSyncStatusRefresh
|
m.SyncStatus = AccountSyncStatusRefresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.AccName = txt.Clip(m.AccName, txt.ClipName)
|
||||||
|
m.AccOwner = txt.Clip(m.AccOwner, txt.ClipName)
|
||||||
|
|
||||||
return db.Save(m).Error
|
return db.Save(m).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ func (m *Account) Updates(values interface{}) error {
|
|||||||
return UnscopedDb().Model(m).UpdateColumns(values).Error
|
return UnscopedDb().Model(m).UpdateColumns(values).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates a column in the database.
|
// Update a column in the database.
|
||||||
func (m *Account) Update(attr string, value interface{}) error {
|
func (m *Account) Update(attr string, value interface{}) error {
|
||||||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||||
}
|
}
|
||||||
|
@@ -31,12 +31,12 @@ type Album struct {
|
|||||||
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
||||||
AlbumUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
AlbumUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
||||||
ParentUID string `gorm:"type:VARBINARY(42);default:'';" json:"ParentUID,omitempty" yaml:"ParentUID,omitempty"`
|
ParentUID string `gorm:"type:VARBINARY(42);default:'';" json:"ParentUID,omitempty" yaml:"ParentUID,omitempty"`
|
||||||
AlbumSlug string `gorm:"type:VARBINARY(255);index;" json:"Slug" yaml:"Slug"`
|
AlbumSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug"`
|
||||||
AlbumPath string `gorm:"type:VARBINARY(500);index;" json:"Path,omitempty" yaml:"Path,omitempty"`
|
AlbumPath string `gorm:"type:VARBINARY(500);index;" json:"Path,omitempty" yaml:"Path,omitempty"`
|
||||||
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
|
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
|
||||||
AlbumTitle string `gorm:"type:VARCHAR(255);index;" json:"Title" yaml:"Title"`
|
AlbumTitle string `gorm:"type:VARCHAR(160);index;" json:"Title" yaml:"Title"`
|
||||||
AlbumLocation string `gorm:"type:VARCHAR(255);" json:"Location" yaml:"Location,omitempty"`
|
AlbumLocation string `gorm:"type:VARCHAR(160);" json:"Location" yaml:"Location,omitempty"`
|
||||||
AlbumCategory string `gorm:"type:VARCHAR(255);index;" json:"Category" yaml:"Category,omitempty"`
|
AlbumCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"`
|
||||||
AlbumCaption string `gorm:"type:TEXT;" json:"Caption" yaml:"Caption,omitempty"`
|
AlbumCaption string `gorm:"type:TEXT;" json:"Caption" yaml:"Caption,omitempty"`
|
||||||
AlbumDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
AlbumDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
||||||
AlbumNotes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
|
AlbumNotes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
|
||||||
@@ -292,7 +292,7 @@ func (m *Album) String() string {
|
|||||||
return "[unknown album]"
|
return "[unknown album]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks if the album is of type moment.
|
// IsMoment tests if the album is of type moment.
|
||||||
func (m *Album) IsMoment() bool {
|
func (m *Album) IsMoment() bool {
|
||||||
return m.AlbumType == AlbumMoment
|
return m.AlbumType == AlbumMoment
|
||||||
}
|
}
|
||||||
@@ -309,9 +309,9 @@ func (m *Album) SetTitle(title string) {
|
|||||||
|
|
||||||
if m.AlbumType == AlbumDefault {
|
if m.AlbumType == AlbumDefault {
|
||||||
if len(m.AlbumTitle) < txt.ClipSlug {
|
if len(m.AlbumTitle) < txt.ClipSlug {
|
||||||
m.AlbumSlug = slug.Make(m.AlbumTitle)
|
m.AlbumSlug = txt.Slug(m.AlbumTitle)
|
||||||
} else {
|
} else {
|
||||||
m.AlbumSlug = slug.Make(txt.Clip(m.AlbumTitle, txt.ClipSlug)) + "-" + m.AlbumUID
|
m.AlbumSlug = txt.Slug(m.AlbumTitle) + "-" + m.AlbumUID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ func (m *Album) SaveForm(f form.Album) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if f.AlbumCategory != "" {
|
if f.AlbumCategory != "" {
|
||||||
m.AlbumCategory = txt.Title(txt.Clip(f.AlbumCategory, txt.ClipKeyword))
|
m.AlbumCategory = txt.Clip(txt.Title(f.AlbumCategory), txt.ClipCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.AlbumTitle != "" {
|
if f.AlbumTitle != "" {
|
||||||
|
@@ -5,7 +5,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
"github.com/photoprism/photoprism/pkg/txt"
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
)
|
)
|
||||||
@@ -18,11 +17,11 @@ type Cameras []Camera
|
|||||||
// Camera model and make (as extracted from UpdateExif metadata)
|
// Camera model and make (as extracted from UpdateExif metadata)
|
||||||
type Camera struct {
|
type Camera struct {
|
||||||
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
|
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
|
||||||
CameraSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
|
CameraSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
||||||
CameraName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
|
CameraName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
||||||
CameraMake string `gorm:"type:VARCHAR(255);" json:"Make" yaml:"Make,omitempty"`
|
CameraMake string `gorm:"type:VARCHAR(160);" json:"Make" yaml:"Make,omitempty"`
|
||||||
CameraModel string `gorm:"type:VARCHAR(255);" json:"Model" yaml:"Model,omitempty"`
|
CameraModel string `gorm:"type:VARCHAR(160);" json:"Model" yaml:"Model,omitempty"`
|
||||||
CameraType string `gorm:"type:VARCHAR(255);" json:"Type,omitempty" yaml:"Type,omitempty"`
|
CameraType string `gorm:"type:VARCHAR(100);" json:"Type,omitempty" yaml:"Type,omitempty"`
|
||||||
CameraDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
CameraDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
||||||
CameraNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
CameraNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
||||||
CreatedAt time.Time `json:"-" yaml:"-"`
|
CreatedAt time.Time `json:"-" yaml:"-"`
|
||||||
@@ -44,8 +43,8 @@ func CreateUnknownCamera() {
|
|||||||
|
|
||||||
// NewCamera creates a camera entity from a model name and a make name.
|
// NewCamera creates a camera entity from a model name and a make name.
|
||||||
func NewCamera(modelName string, makeName string) *Camera {
|
func NewCamera(modelName string, makeName string) *Camera {
|
||||||
modelName = txt.Clip(modelName, txt.ClipDefault)
|
modelName = strings.TrimSpace(modelName)
|
||||||
makeName = txt.Clip(makeName, txt.ClipDefault)
|
makeName = strings.TrimSpace(makeName)
|
||||||
|
|
||||||
if modelName == "" && makeName == "" {
|
if modelName == "" && makeName == "" {
|
||||||
return &UnknownCamera
|
return &UnknownCamera
|
||||||
@@ -72,13 +71,12 @@ func NewCamera(modelName string, makeName string) *Camera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cameraName := strings.Join(name, " ")
|
cameraName := strings.Join(name, " ")
|
||||||
cameraSlug := slug.Make(txt.Clip(cameraName, txt.ClipSlug))
|
|
||||||
|
|
||||||
result := &Camera{
|
result := &Camera{
|
||||||
CameraSlug: cameraSlug,
|
CameraSlug: txt.Slug(cameraName),
|
||||||
CameraName: cameraName,
|
CameraName: txt.Clip(cameraName, txt.ClipName),
|
||||||
CameraMake: makeName,
|
CameraMake: txt.Clip(makeName, txt.ClipName),
|
||||||
CameraModel: modelName,
|
CameraModel: txt.Clip(modelName, txt.ClipName),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
"github.com/photoprism/photoprism/internal/maps"
|
"github.com/photoprism/photoprism/internal/maps"
|
||||||
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// altCountryNames defines mapping between different names for the same country
|
// altCountryNames defines mapping between different names for the same country
|
||||||
@@ -20,8 +20,8 @@ type Countries []Country
|
|||||||
// Country represents a country location, used for labeling photos.
|
// Country represents a country location, used for labeling photos.
|
||||||
type Country struct {
|
type Country struct {
|
||||||
ID string `gorm:"type:VARBINARY(2);primary_key" json:"ID" yaml:"ID"`
|
ID string `gorm:"type:VARBINARY(2);primary_key" json:"ID" yaml:"ID"`
|
||||||
CountrySlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
|
CountrySlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
||||||
CountryName string `json:"Name" yaml:"Name,omitempty"`
|
CountryName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name,omitempty"`
|
||||||
CountryDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
CountryDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
||||||
CountryNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
CountryNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
||||||
CountryPhoto *Photo `json:"-" yaml:"-"`
|
CountryPhoto *Photo `json:"-" yaml:"-"`
|
||||||
@@ -51,12 +51,10 @@ func NewCountry(countryCode string, countryName string) *Country {
|
|||||||
countryName = altName
|
countryName = altName
|
||||||
}
|
}
|
||||||
|
|
||||||
countrySlug := slug.MakeLang(countryName, "en")
|
|
||||||
|
|
||||||
result := &Country{
|
result := &Country{
|
||||||
ID: countryCode,
|
ID: countryCode,
|
||||||
CountryName: countryName,
|
CountryName: txt.Clip(countryName, txt.ClipName),
|
||||||
CountrySlug: countrySlug,
|
CountrySlug: txt.Slug(countryName),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@@ -10,6 +10,9 @@ import (
|
|||||||
|
|
||||||
var photoDetailsMutex = sync.Mutex{}
|
var photoDetailsMutex = sync.Mutex{}
|
||||||
|
|
||||||
|
// ClipDetail is the size of a Details database column in runes.
|
||||||
|
const ClipDetail = 250
|
||||||
|
|
||||||
// Details stores additional metadata fields for each photo to improve search performance.
|
// Details stores additional metadata fields for each photo to improve search performance.
|
||||||
type Details struct {
|
type Details struct {
|
||||||
PhotoID uint `gorm:"primary_key;auto_increment:false" yaml:"-"`
|
PhotoID uint `gorm:"primary_key;auto_increment:false" yaml:"-"`
|
||||||
@@ -17,13 +20,13 @@ type Details struct {
|
|||||||
KeywordsSrc string `gorm:"type:VARBINARY(8);" json:"KeywordsSrc" yaml:"KeywordsSrc,omitempty"`
|
KeywordsSrc string `gorm:"type:VARBINARY(8);" json:"KeywordsSrc" yaml:"KeywordsSrc,omitempty"`
|
||||||
Notes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
|
Notes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
|
||||||
NotesSrc string `gorm:"type:VARBINARY(8);" json:"NotesSrc" yaml:"NotesSrc,omitempty"`
|
NotesSrc string `gorm:"type:VARBINARY(8);" json:"NotesSrc" yaml:"NotesSrc,omitempty"`
|
||||||
Subject string `gorm:"type:VARCHAR(255);" json:"Subject" yaml:"Subject,omitempty"`
|
Subject string `gorm:"type:VARCHAR(250);" json:"Subject" yaml:"Subject,omitempty"`
|
||||||
SubjectSrc string `gorm:"type:VARBINARY(8);" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
|
SubjectSrc string `gorm:"type:VARBINARY(8);" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
|
||||||
Artist string `gorm:"type:VARCHAR(255);" json:"Artist" yaml:"Artist,omitempty"`
|
Artist string `gorm:"type:VARCHAR(250);" json:"Artist" yaml:"Artist,omitempty"`
|
||||||
ArtistSrc string `gorm:"type:VARBINARY(8);" json:"ArtistSrc" yaml:"ArtistSrc,omitempty"`
|
ArtistSrc string `gorm:"type:VARBINARY(8);" json:"ArtistSrc" yaml:"ArtistSrc,omitempty"`
|
||||||
Copyright string `gorm:"type:VARCHAR(255);" json:"Copyright" yaml:"Copyright,omitempty"`
|
Copyright string `gorm:"type:VARCHAR(250);" json:"Copyright" yaml:"Copyright,omitempty"`
|
||||||
CopyrightSrc string `gorm:"type:VARBINARY(8);" json:"CopyrightSrc" yaml:"CopyrightSrc,omitempty"`
|
CopyrightSrc string `gorm:"type:VARBINARY(8);" json:"CopyrightSrc" yaml:"CopyrightSrc,omitempty"`
|
||||||
License string `gorm:"type:VARCHAR(255);" json:"License" yaml:"License,omitempty"`
|
License string `gorm:"type:VARCHAR(250);" json:"License" yaml:"License,omitempty"`
|
||||||
LicenseSrc string `gorm:"type:VARBINARY(8);" json:"LicenseSrc" yaml:"LicenseSrc,omitempty"`
|
LicenseSrc string `gorm:"type:VARBINARY(8);" json:"LicenseSrc" yaml:"LicenseSrc,omitempty"`
|
||||||
CreatedAt time.Time `yaml:"-"`
|
CreatedAt time.Time `yaml:"-"`
|
||||||
UpdatedAt time.Time `yaml:"-"`
|
UpdatedAt time.Time `yaml:"-"`
|
||||||
@@ -160,7 +163,7 @@ func (m *Details) SetKeywords(data, src string) {
|
|||||||
|
|
||||||
// SetSubject updates the photo details field.
|
// SetSubject updates the photo details field.
|
||||||
func (m *Details) SetSubject(data, src string) {
|
func (m *Details) SetSubject(data, src string) {
|
||||||
val := txt.Clip(data, txt.ClipVarchar)
|
val := txt.Clip(data, ClipDetail)
|
||||||
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return
|
return
|
||||||
@@ -192,7 +195,7 @@ func (m *Details) SetNotes(data, src string) {
|
|||||||
|
|
||||||
// SetArtist updates the photo details field.
|
// SetArtist updates the photo details field.
|
||||||
func (m *Details) SetArtist(data, src string) {
|
func (m *Details) SetArtist(data, src string) {
|
||||||
val := txt.Clip(data, txt.ClipVarchar)
|
val := txt.Clip(data, ClipDetail)
|
||||||
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return
|
return
|
||||||
@@ -208,7 +211,7 @@ func (m *Details) SetArtist(data, src string) {
|
|||||||
|
|
||||||
// SetCopyright updates the photo details field.
|
// SetCopyright updates the photo details field.
|
||||||
func (m *Details) SetCopyright(data, src string) {
|
func (m *Details) SetCopyright(data, src string) {
|
||||||
val := txt.Clip(data, txt.ClipVarchar)
|
val := txt.Clip(data, ClipDetail)
|
||||||
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return
|
return
|
||||||
@@ -224,7 +227,7 @@ func (m *Details) SetCopyright(data, src string) {
|
|||||||
|
|
||||||
// SetLicense updates the photo details field.
|
// SetLicense updates the photo details field.
|
||||||
func (m *Details) SetLicense(data, src string) {
|
func (m *Details) SetLicense(data, src string) {
|
||||||
val := txt.Clip(data, txt.ClipVarchar)
|
val := txt.Clip(data, ClipDetail)
|
||||||
|
|
||||||
if val == "" {
|
if val == "" {
|
||||||
return
|
return
|
||||||
|
@@ -13,6 +13,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
)
|
)
|
||||||
@@ -71,10 +73,10 @@ func (list Types) WaitForMigration() {
|
|||||||
for i := 0; i <= attempts; i++ {
|
for i := 0; i <= attempts; i++ {
|
||||||
count := RowCount{}
|
count := RowCount{}
|
||||||
if err := Db().Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil {
|
if err := Db().Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil {
|
||||||
// log.Debugf("entity: table %s migrated", name)
|
log.Tracef("entity: %s migrated", txt.Quote(name))
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("entity: wait for migration %s (%s)", err.Error(), name)
|
log.Debugf("entity: waiting for %s migration (%s)", txt.Quote(name), err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if i == attempts {
|
if i == attempts {
|
||||||
@@ -93,20 +95,21 @@ func (list Types) Truncate() {
|
|||||||
// log.Debugf("entity: removed all data from %s", name)
|
// log.Debugf("entity: removed all data from %s", name)
|
||||||
break
|
break
|
||||||
} else if err.Error() != "record not found" {
|
} else if err.Error() != "record not found" {
|
||||||
log.Debugf("entity: %s in %s", err, name)
|
log.Debugf("entity: %s in %s", err, txt.Quote(name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate migrates all database tables of registered entities.
|
// Migrate migrates all database tables of registered entities.
|
||||||
func (list Types) Migrate() {
|
func (list Types) Migrate() {
|
||||||
for _, entity := range list {
|
for name, entity := range list {
|
||||||
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil {
|
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil {
|
||||||
log.Debugf("entity: migrate %s (waiting 1s)", err.Error())
|
log.Debugf("entity: %s (waiting 1s)", err.Error())
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil {
|
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil {
|
||||||
|
log.Errorf("entity: failed migrating %s", txt.Quote(name))
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -351,8 +351,8 @@ func FindFace(id string) *Face {
|
|||||||
return &f
|
return &f
|
||||||
}
|
}
|
||||||
|
|
||||||
// FaceCount counts the number of valid face markers for a file uid.
|
// ValidFaceCount counts the number of valid face markers for a file uid.
|
||||||
func FaceCount(fileUID string) (c int) {
|
func ValidFaceCount(fileUID string) (c int) {
|
||||||
if !rnd.IsPPID(fileUID, 'f') {
|
if !rnd.IsPPID(fileUID, 'f') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@@ -445,9 +445,9 @@ func (m *File) AddFace(f face.Face, subjUID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FaceCount returns the current number of valid faces detected.
|
// ValidFaceCount returns the number of valid face markers.
|
||||||
func (m *File) FaceCount() (c int) {
|
func (m *File) ValidFaceCount() (c int) {
|
||||||
return FaceCount(m.FileUID)
|
return ValidFaceCount(m.FileUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePhotoFaceCount updates the faces count in the index and returns it if the file is primary.
|
// UpdatePhotoFaceCount updates the faces count in the index and returns it if the file is primary.
|
||||||
@@ -457,7 +457,7 @@ func (m *File) UpdatePhotoFaceCount() (c int, err error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c = m.FaceCount()
|
c = m.ValidFaceCount()
|
||||||
|
|
||||||
err = UnscopedDb().Model(Photo{}).
|
err = UnscopedDb().Model(Photo{}).
|
||||||
Where("id = ?", m.PhotoID).
|
Where("id = ?", m.PhotoID).
|
||||||
@@ -491,6 +491,15 @@ func (m *File) Markers() *Markers {
|
|||||||
return m.markers
|
return m.markers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnsavedMarkers tests if any marker hasn't been saved yet.
|
||||||
|
func (m *File) UnsavedMarkers() bool {
|
||||||
|
if m.markers == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.markers.Unsaved()
|
||||||
|
}
|
||||||
|
|
||||||
// SubjectNames returns all known subject names.
|
// SubjectNames returns all known subject names.
|
||||||
func (m *File) SubjectNames() []string {
|
func (m *File) SubjectNames() []string {
|
||||||
return m.Markers().SubjectNames()
|
return m.Markers().SubjectNames()
|
||||||
|
@@ -506,11 +506,11 @@ func TestFile_AddFaces(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFile_FaceCount(t *testing.T) {
|
func TestFile_ValidFaceCount(t *testing.T) {
|
||||||
t.Run("FileFixturesExampleBridge", func(t *testing.T) {
|
t.Run("FileFixturesExampleBridge", func(t *testing.T) {
|
||||||
file := FileFixturesExampleBridge
|
file := FileFixturesExampleBridge
|
||||||
|
|
||||||
result := file.FaceCount()
|
result := file.ValidFaceCount()
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, result, 3)
|
assert.GreaterOrEqual(t, result, 3)
|
||||||
})
|
})
|
||||||
@@ -589,3 +589,29 @@ func TestFile_SubjectNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFile_UnsavedMarkers(t *testing.T) {
|
||||||
|
t.Run("bridge2.jpg", func(t *testing.T) {
|
||||||
|
m := FileFixtures.Get("bridge2.jpg")
|
||||||
|
assert.Equal(t, "ft2es49w15bnlqdw", m.FileUID)
|
||||||
|
assert.False(t, m.UnsavedMarkers())
|
||||||
|
|
||||||
|
markers := m.Markers()
|
||||||
|
|
||||||
|
assert.Equal(t, 1, m.ValidFaceCount())
|
||||||
|
assert.Equal(t, 1, markers.ValidFaceCount())
|
||||||
|
assert.Equal(t, 1, markers.DetectedFaceCount())
|
||||||
|
assert.False(t, m.UnsavedMarkers())
|
||||||
|
assert.False(t, markers.Unsaved())
|
||||||
|
|
||||||
|
newMarker := *NewMarker(m, cropArea1, "lt9k3pw1wowuy1c1", SrcManual, MarkerFace, 100, 65)
|
||||||
|
|
||||||
|
markers.Append(newMarker)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, m.ValidFaceCount())
|
||||||
|
assert.Equal(t, 2, markers.ValidFaceCount())
|
||||||
|
assert.Equal(t, 1, markers.DetectedFaceCount())
|
||||||
|
assert.True(t, m.UnsavedMarkers())
|
||||||
|
assert.True(t, markers.Unsaved())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@@ -7,7 +7,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/internal/form"
|
"github.com/photoprism/photoprism/internal/form"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
@@ -25,8 +24,8 @@ type Folder struct {
|
|||||||
Root string `gorm:"type:VARBINARY(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"`
|
Root string `gorm:"type:VARBINARY(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"`
|
||||||
FolderUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
FolderUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||||
FolderType string `gorm:"type:VARBINARY(16);" json:"Type" yaml:"Type,omitempty"`
|
FolderType string `gorm:"type:VARBINARY(16);" json:"Type" yaml:"Type,omitempty"`
|
||||||
FolderTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title,omitempty"`
|
FolderTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title,omitempty"`
|
||||||
FolderCategory string `gorm:"type:VARCHAR(255);index;" json:"Category" yaml:"Category,omitempty"`
|
FolderCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"`
|
||||||
FolderDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
FolderDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
||||||
FolderOrder string `gorm:"type:VARBINARY(32);" json:"Order" yaml:"Order,omitempty"`
|
FolderOrder string `gorm:"type:VARBINARY(32);" json:"Order" yaml:"Order,omitempty"`
|
||||||
FolderCountry string `gorm:"type:VARBINARY(2);index:idx_folders_country_year_month;default:'zz'" json:"Country" yaml:"Country,omitempty"`
|
FolderCountry string `gorm:"type:VARBINARY(2);index:idx_folders_country_year_month;default:'zz'" json:"Country" yaml:"Country,omitempty"`
|
||||||
@@ -130,13 +129,13 @@ func (m *Folder) SetValuesFromPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.FolderTitle == "" {
|
if m.FolderTitle == "" {
|
||||||
m.FolderTitle = txt.Title(s)
|
m.FolderTitle = txt.Clip(txt.Title(s), txt.ClipTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slug returns a slug based on the folder title.
|
// Slug returns a slug based on the folder title.
|
||||||
func (m *Folder) Slug() string {
|
func (m *Folder) Slug() string {
|
||||||
return slug.Make(m.Path)
|
return txt.Slug(m.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootPath returns the full folder path including root.
|
// RootPath returns the full folder path including root.
|
||||||
@@ -144,12 +143,12 @@ func (m *Folder) RootPath() string {
|
|||||||
return path.Join(m.Root, m.Path)
|
return path.Join(m.Root, m.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title returns a human readable folder title.
|
// Title returns the human-readable folder title.
|
||||||
func (m *Folder) Title() string {
|
func (m *Folder) Title() string {
|
||||||
return m.FolderTitle
|
return m.FolderTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves the complete entity in the database.
|
// Create inserts the entity to the index.
|
||||||
func (m *Folder) Create() error {
|
func (m *Folder) Create() error {
|
||||||
folderMutex.Lock()
|
folderMutex.Lock()
|
||||||
defer folderMutex.Unlock()
|
defer folderMutex.Unlock()
|
||||||
@@ -232,5 +231,8 @@ func (m *Folder) SetForm(f form.Folder) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.FolderTitle = txt.Clip(m.FolderTitle, txt.ClipTitle)
|
||||||
|
m.FolderCategory = txt.Clip(m.FolderCategory, txt.ClipCategory)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/internal/classify"
|
"github.com/photoprism/photoprism/internal/classify"
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
@@ -20,9 +19,9 @@ type Labels []Label
|
|||||||
type Label struct {
|
type Label struct {
|
||||||
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
||||||
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
||||||
LabelSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
|
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
||||||
CustomSlug string `gorm:"type:VARBINARY(255);index;" json:"CustomSlug" yaml:"-"`
|
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
|
||||||
LabelName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
|
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
||||||
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
|
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
|
||||||
LabelFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
|
LabelFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
|
||||||
LabelDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
LabelDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
||||||
@@ -60,12 +59,12 @@ func NewLabel(name string, priority int) *Label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
labelName = txt.Title(labelName)
|
labelName = txt.Title(labelName)
|
||||||
labelSlug := slug.Make(txt.Clip(labelName, txt.ClipSlug))
|
labelSlug := txt.Slug(labelName)
|
||||||
|
|
||||||
result := &Label{
|
result := &Label{
|
||||||
LabelSlug: labelSlug,
|
LabelSlug: labelSlug,
|
||||||
CustomSlug: labelSlug,
|
CustomSlug: labelSlug,
|
||||||
LabelName: labelName,
|
LabelName: txt.Clip(labelName, txt.ClipName),
|
||||||
LabelPriority: priority,
|
LabelPriority: priority,
|
||||||
PhotoCount: 1,
|
PhotoCount: 1,
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ func FirstOrCreateLabel(m *Label) *Label {
|
|||||||
|
|
||||||
// FindLabel returns an existing row if exists.
|
// FindLabel returns an existing row if exists.
|
||||||
func FindLabel(s string) *Label {
|
func FindLabel(s string) *Label {
|
||||||
labelSlug := slug.Make(txt.Clip(s, txt.ClipSlug))
|
labelSlug := txt.Slug(s)
|
||||||
|
|
||||||
result := Label{}
|
result := Label{}
|
||||||
|
|
||||||
@@ -167,8 +166,8 @@ func (m *Label) SetName(name string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.LabelName = name
|
m.LabelName = txt.Clip(name, txt.ClipName)
|
||||||
m.CustomSlug = txt.NameSlug(name)
|
m.CustomSlug = txt.Slug(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateClassify updates a label if necessary
|
// UpdateClassify updates a label if necessary
|
||||||
|
@@ -5,7 +5,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
"github.com/photoprism/photoprism/pkg/txt"
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
)
|
)
|
||||||
@@ -18,11 +17,11 @@ type Lenses []Lens
|
|||||||
// Lens represents camera lens (as extracted from UpdateExif metadata)
|
// Lens represents camera lens (as extracted from UpdateExif metadata)
|
||||||
type Lens struct {
|
type Lens struct {
|
||||||
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
|
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
|
||||||
LensSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"Slug,omitempty"`
|
LensSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"Slug,omitempty"`
|
||||||
LensName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
|
LensName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
||||||
LensMake string `gorm:"type:VARCHAR(255);" json:"Make" yaml:"Make,omitempty"`
|
LensMake string `gorm:"type:VARCHAR(160);" json:"Make" yaml:"Make,omitempty"`
|
||||||
LensModel string `gorm:"type:VARCHAR(255);" json:"Model" yaml:"Model,omitempty"`
|
LensModel string `gorm:"type:VARCHAR(160);" json:"Model" yaml:"Model,omitempty"`
|
||||||
LensType string `gorm:"type:VARCHAR(255);" json:"Type" yaml:"Type,omitempty"`
|
LensType string `gorm:"type:VARCHAR(100);" json:"Type" yaml:"Type,omitempty"`
|
||||||
LensDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
LensDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
|
||||||
LensNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
LensNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
||||||
CreatedAt time.Time `json:"-" yaml:"-"`
|
CreatedAt time.Time `json:"-" yaml:"-"`
|
||||||
@@ -49,8 +48,8 @@ func (Lens) TableName() string {
|
|||||||
|
|
||||||
// NewLens creates a new lens in database
|
// NewLens creates a new lens in database
|
||||||
func NewLens(modelName string, makeName string) *Lens {
|
func NewLens(modelName string, makeName string) *Lens {
|
||||||
modelName = txt.Clip(modelName, txt.ClipDefault)
|
modelName = strings.TrimSpace(modelName)
|
||||||
makeName = txt.Clip(makeName, txt.ClipDefault)
|
makeName = strings.TrimSpace(makeName)
|
||||||
|
|
||||||
if modelName == "" && makeName == "" {
|
if modelName == "" && makeName == "" {
|
||||||
return &UnknownLens
|
return &UnknownLens
|
||||||
@@ -73,13 +72,12 @@ func NewLens(modelName string, makeName string) *Lens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lensName := strings.Join(name, " ")
|
lensName := strings.Join(name, " ")
|
||||||
lensSlug := slug.Make(txt.Clip(lensName, txt.ClipSlug))
|
|
||||||
|
|
||||||
result := &Lens{
|
result := &Lens{
|
||||||
LensSlug: lensSlug,
|
LensSlug: txt.Slug(lensName),
|
||||||
LensName: lensName,
|
LensName: txt.Clip(lensName, txt.ClipName),
|
||||||
LensMake: makeName,
|
LensMake: txt.Clip(makeName, txt.ClipName),
|
||||||
LensModel: modelName,
|
LensModel: txt.Clip(modelName, txt.ClipName),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
@@ -17,8 +16,8 @@ type Links []Link
|
|||||||
type Link struct {
|
type Link struct {
|
||||||
LinkUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
LinkUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||||
ShareUID string `gorm:"type:VARBINARY(42);unique_index:idx_links_uid_token;" json:"Share" yaml:"Share"`
|
ShareUID string `gorm:"type:VARBINARY(42);unique_index:idx_links_uid_token;" json:"Share" yaml:"Share"`
|
||||||
ShareSlug string `gorm:"type:VARBINARY(255);index;" json:"Slug" yaml:"Slug,omitempty"`
|
ShareSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug,omitempty"`
|
||||||
LinkToken string `gorm:"type:VARBINARY(255);unique_index:idx_links_uid_token;" json:"Token" yaml:"Token,omitempty"`
|
LinkToken string `gorm:"type:VARBINARY(160);unique_index:idx_links_uid_token;" json:"Token" yaml:"Token,omitempty"`
|
||||||
LinkExpires int `json:"Expires" yaml:"Expires,omitempty"`
|
LinkExpires int `json:"Expires" yaml:"Expires,omitempty"`
|
||||||
LinkViews uint `json:"Views" yaml:"-"`
|
LinkViews uint `json:"Views" yaml:"-"`
|
||||||
MaxViews uint `json:"MaxViews" yaml:"-"`
|
MaxViews uint `json:"MaxViews" yaml:"-"`
|
||||||
@@ -81,7 +80,7 @@ func (m *Link) Expired() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Link) SetSlug(s string) {
|
func (m *Link) SetSlug(s string) {
|
||||||
m.ShareSlug = slug.Make(txt.Clip(s, txt.ClipSlug))
|
m.ShareSlug = txt.Slug(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Link) SetPassword(password string) error {
|
func (m *Link) SetPassword(password string) error {
|
||||||
|
@@ -29,7 +29,7 @@ type Marker struct {
|
|||||||
FileUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"FileUID" yaml:"FileUID"`
|
FileUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"FileUID" yaml:"FileUID"`
|
||||||
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
|
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
|
||||||
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
|
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
|
||||||
MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"`
|
MarkerName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name,omitempty"`
|
||||||
MarkerReview bool `json:"Review" yaml:"Review,omitempty"`
|
MarkerReview bool `json:"Review" yaml:"Review,omitempty"`
|
||||||
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
|
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
|
||||||
SubjUID string `gorm:"type:VARBINARY(42);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
SubjUID string `gorm:"type:VARBINARY(42);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
||||||
@@ -552,6 +552,49 @@ func (m *Marker) OverlapPercent(marker Marker) int {
|
|||||||
return int(math.Round(marker.SurfaceRatio(m.OverlapArea(marker)) * 100))
|
return int(math.Round(marker.SurfaceRatio(m.OverlapArea(marker)) * 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unsaved tests if the marker hasn't been saved yet.
|
||||||
|
func (m *Marker) Unsaved() bool {
|
||||||
|
return m.MarkerUID == "" || m.CreatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidFace tests if the marker is a valid face.
|
||||||
|
func (m *Marker) ValidFace() bool {
|
||||||
|
return m.MarkerType == MarkerFace && !m.MarkerInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectedFace tests if the marker is an automatically detected face.
|
||||||
|
func (m *Marker) DetectedFace() bool {
|
||||||
|
return m.MarkerType == MarkerFace && m.MarkerSrc == SrcImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncertainty returns the detection uncertainty based on the score in percent.
|
||||||
|
func (m *Marker) Uncertainty() int {
|
||||||
|
switch {
|
||||||
|
case m.Score > 300:
|
||||||
|
return 1
|
||||||
|
case m.Score > 200:
|
||||||
|
return 5
|
||||||
|
case m.Score > 100:
|
||||||
|
return 10
|
||||||
|
case m.Score > 80:
|
||||||
|
return 15
|
||||||
|
case m.Score > 65:
|
||||||
|
return 20
|
||||||
|
case m.Score > 50:
|
||||||
|
return 25
|
||||||
|
case m.Score > 40:
|
||||||
|
return 30
|
||||||
|
case m.Score > 30:
|
||||||
|
return 35
|
||||||
|
case m.Score > 20:
|
||||||
|
return 40
|
||||||
|
case m.Score > 10:
|
||||||
|
return 45
|
||||||
|
}
|
||||||
|
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
// FindMarker returns an existing row if exists.
|
// FindMarker returns an existing row if exists.
|
||||||
func FindMarker(markerUid string) *Marker {
|
func FindMarker(markerUid string) *Marker {
|
||||||
if markerUid == "" {
|
if markerUid == "" {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/photoprism/photoprism/internal/classify"
|
||||||
"github.com/photoprism/photoprism/internal/face"
|
"github.com/photoprism/photoprism/internal/face"
|
||||||
"github.com/photoprism/photoprism/pkg/txt"
|
"github.com/photoprism/photoprism/pkg/txt"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,17 @@ func (m Markers) Save(file *File) (count int, err error) {
|
|||||||
return file.UpdatePhotoFaceCount()
|
return file.UpdatePhotoFaceCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unsaved tests if any marker hasn't been saved yet.
|
||||||
|
func (m Markers) Unsaved() bool {
|
||||||
|
for _, marker := range m {
|
||||||
|
if marker.Unsaved() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Contains returns true if a marker at the same position already exists.
|
// Contains returns true if a marker at the same position already exists.
|
||||||
func (m Markers) Contains(other Marker) bool {
|
func (m Markers) Contains(other Marker) bool {
|
||||||
for _, marker := range m {
|
for _, marker := range m {
|
||||||
@@ -37,15 +49,26 @@ func (m Markers) Contains(other Marker) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// FaceCount returns the number of valid face markers.
|
// DetectedFaceCount returns the number of automatically detected face markers.
|
||||||
func (m Markers) FaceCount() (faces int) {
|
func (m Markers) DetectedFaceCount() (count int) {
|
||||||
for _, marker := range m {
|
for _, marker := range m {
|
||||||
if !marker.MarkerInvalid && marker.MarkerType == MarkerFace {
|
if marker.DetectedFace() {
|
||||||
faces++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return faces
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidFaceCount returns the number of valid face markers.
|
||||||
|
func (m Markers) ValidFaceCount() (count int) {
|
||||||
|
for _, marker := range m {
|
||||||
|
if marker.ValidFace() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubjectNames returns known subject names.
|
// SubjectNames returns known subject names.
|
||||||
@@ -61,6 +84,48 @@ func (m Markers) SubjectNames() (names []string) {
|
|||||||
return txt.UniqueNames(names)
|
return txt.UniqueNames(names)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Labels returns matching labels.
|
||||||
|
func (m Markers) Labels() (result classify.Labels) {
|
||||||
|
faceCount := 0
|
||||||
|
|
||||||
|
labelSrc := SrcImage
|
||||||
|
labelUncertainty := 100
|
||||||
|
|
||||||
|
for _, marker := range m {
|
||||||
|
if marker.ValidFace() {
|
||||||
|
faceCount++
|
||||||
|
|
||||||
|
if u := marker.Uncertainty(); u < labelUncertainty {
|
||||||
|
labelUncertainty = u
|
||||||
|
}
|
||||||
|
|
||||||
|
if marker.MarkerSrc != "" {
|
||||||
|
labelSrc = marker.MarkerSrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if faceCount < 1 {
|
||||||
|
return classify.Labels{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule classify.LabelRule
|
||||||
|
|
||||||
|
if faceCount == 1 {
|
||||||
|
rule = classify.Rules["portrait"]
|
||||||
|
} else {
|
||||||
|
rule = classify.Rules["people"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return classify.Labels{classify.Label{
|
||||||
|
Name: rule.Label,
|
||||||
|
Source: labelSrc,
|
||||||
|
Uncertainty: labelUncertainty,
|
||||||
|
Priority: rule.Priority,
|
||||||
|
Categories: rule.Categories,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// Append adds a marker.
|
// Append adds a marker.
|
||||||
func (m *Markers) Append(marker Marker) {
|
func (m *Markers) Append(marker Marker) {
|
||||||
*m = append(*m, marker)
|
*m = append(*m, marker)
|
||||||
|
@@ -63,15 +63,26 @@ func TestMarkers_Contains(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkers_FaceCount(t *testing.T) {
|
func TestMarkers_DetectedFaceCount(t *testing.T) {
|
||||||
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 65)
|
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 65)
|
||||||
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 100, 65)
|
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcManual, MarkerFace, 100, 65)
|
||||||
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 100, 65)
|
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcManual, MarkerFace, 100, 65)
|
||||||
m3.MarkerInvalid = true
|
m3.MarkerInvalid = true
|
||||||
|
|
||||||
m := Markers{m1, m2, m3}
|
m := Markers{m1, m2, m3}
|
||||||
|
|
||||||
assert.Equal(t, 2, m.FaceCount())
|
assert.Equal(t, 1, m.DetectedFaceCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkers_ValidFaceCount(t *testing.T) {
|
||||||
|
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 65)
|
||||||
|
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcManual, MarkerFace, 100, 65)
|
||||||
|
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcManual, MarkerFace, 100, 65)
|
||||||
|
m3.MarkerInvalid = true
|
||||||
|
|
||||||
|
m := Markers{m1, m2, m3}
|
||||||
|
|
||||||
|
assert.Equal(t, 2, m.ValidFaceCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarkers_SubjectNames(t *testing.T) {
|
func TestMarkers_SubjectNames(t *testing.T) {
|
||||||
@@ -85,3 +96,63 @@ func TestMarkers_SubjectNames(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, []string{"Jens Mander", "Corn McCornface"}, m.SubjectNames())
|
assert.Equal(t, []string{"Jens Mander", "Corn McCornface"}, m.SubjectNames())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkers_Labels(t *testing.T) {
|
||||||
|
t.Run("None", func(t *testing.T) {
|
||||||
|
m := Markers{}
|
||||||
|
|
||||||
|
result := m.Labels()
|
||||||
|
|
||||||
|
if len(result) > 0 {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("One", func(t *testing.T) {
|
||||||
|
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 12)
|
||||||
|
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 100, 300)
|
||||||
|
|
||||||
|
m2.MarkerInvalid = true
|
||||||
|
|
||||||
|
m := Markers{m1, m2}
|
||||||
|
|
||||||
|
result := m.Labels()
|
||||||
|
|
||||||
|
if len(result) == 1 {
|
||||||
|
t.Logf("labels: %#v", result)
|
||||||
|
|
||||||
|
assert.Equal(t, "portrait", result[0].Name)
|
||||||
|
assert.Equal(t, SrcImage, result[0].Source)
|
||||||
|
assert.Equal(t, 45, result[0].Uncertainty)
|
||||||
|
assert.Equal(t, 0, result[0].Priority)
|
||||||
|
assert.Len(t, result[0].Categories, 1)
|
||||||
|
|
||||||
|
if len(result[0].Categories) == 1 {
|
||||||
|
assert.Equal(t, "people", result[0].Categories[0])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("Many", func(t *testing.T) {
|
||||||
|
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 65)
|
||||||
|
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 100, 65)
|
||||||
|
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 100, 65)
|
||||||
|
m3.MarkerInvalid = true
|
||||||
|
|
||||||
|
m := Markers{m1, m2, m3}
|
||||||
|
|
||||||
|
result := m.Labels()
|
||||||
|
|
||||||
|
if len(result) == 1 {
|
||||||
|
t.Logf("labels: %#v", result)
|
||||||
|
|
||||||
|
assert.Equal(t, "people", result[0].Name)
|
||||||
|
assert.Equal(t, SrcImage, result[0].Source)
|
||||||
|
assert.Equal(t, 25, result[0].Uncertainty)
|
||||||
|
assert.Equal(t, 0, result[0].Priority)
|
||||||
|
assert.Len(t, result[0].Categories, 0)
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@@ -49,7 +49,7 @@ type Photo struct {
|
|||||||
PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"`
|
PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"`
|
||||||
PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"`
|
PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"`
|
||||||
TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"`
|
TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"`
|
||||||
PhotoTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"`
|
PhotoTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title"`
|
||||||
TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"`
|
TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"`
|
||||||
PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
|
||||||
DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"`
|
DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"`
|
||||||
@@ -82,7 +82,7 @@ type Photo struct {
|
|||||||
PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"`
|
PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"`
|
||||||
PhotoColor uint8 `json:"Color" yaml:"-"`
|
PhotoColor uint8 `json:"Color" yaml:"-"`
|
||||||
CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"`
|
CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"`
|
||||||
CameraSerial string `gorm:"type:VARBINARY(255);" json:"CameraSerial" yaml:"CameraSerial,omitempty"`
|
CameraSerial string `gorm:"type:VARBINARY(160);" json:"CameraSerial" yaml:"CameraSerial,omitempty"`
|
||||||
CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"`
|
CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"`
|
||||||
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
|
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
|
||||||
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
|
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
|
||||||
@@ -1073,8 +1073,8 @@ func (m *Photo) MapKey() string {
|
|||||||
|
|
||||||
// SetCameraSerial updates the camera serial number.
|
// SetCameraSerial updates the camera serial number.
|
||||||
func (m *Photo) SetCameraSerial(s string) {
|
func (m *Photo) SetCameraSerial(s string) {
|
||||||
if val := txt.Clip(s, txt.ClipVarchar); m.NoCameraSerial() && val != "" {
|
if s = txt.Clip(s, txt.ClipDefault); m.NoCameraSerial() && s != "" {
|
||||||
m.CameraSerial = val
|
m.CameraSerial = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,6 +1083,6 @@ func (m *Photo) FaceCount() int {
|
|||||||
if f, err := m.PrimaryFile(); err != nil {
|
if f, err := m.PrimaryFile(); err != nil {
|
||||||
return 0
|
return 0
|
||||||
} else {
|
} else {
|
||||||
return f.FaceCount()
|
return f.ValidFaceCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ package entity
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
@@ -146,10 +147,22 @@ func UpdateLabelPhotoCounts() (err error) {
|
|||||||
// UpdatePhotoCounts updates precalculated photo and file counts.
|
// UpdatePhotoCounts updates precalculated photo and file counts.
|
||||||
func UpdatePhotoCounts() (err error) {
|
func UpdatePhotoCounts() (err error) {
|
||||||
if err = UpdatePlacesPhotoCounts(); err != nil {
|
if err = UpdatePlacesPhotoCounts(); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("counts: failed updating places, deprecated or unsupported database")
|
||||||
|
log.Tracef("counts: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = UpdateSubjectFileCounts(); err != nil {
|
if err = UpdateSubjectFileCounts(); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("counts: failed updating subjects, deprecated or unsupported database")
|
||||||
|
log.Tracef("counts: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -22,9 +22,9 @@ func (m *Photo) NoTitle() bool {
|
|||||||
|
|
||||||
// SetTitle changes the photo title and clips it to 300 characters.
|
// SetTitle changes the photo title and clips it to 300 characters.
|
||||||
func (m *Photo) SetTitle(title, source string) {
|
func (m *Photo) SetTitle(title, source string) {
|
||||||
newTitle := txt.Clip(title, txt.ClipDefault)
|
title = txt.Shorten(title, txt.ClipTitle, txt.Ellipsis)
|
||||||
|
|
||||||
if newTitle == "" {
|
if title == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ func (m *Photo) SetTitle(title, source string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.PhotoTitle = newTitle
|
m.PhotoTitle = title
|
||||||
m.TitleSrc = source
|
m.TitleSrc = source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,10 +3,10 @@ package entity
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
@@ -23,17 +23,17 @@ type Subject struct {
|
|||||||
SubjUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
|
SubjUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
|
||||||
SubjType string `gorm:"type:VARBINARY(8);default:'';" json:"Type,omitempty" yaml:"Type,omitempty"`
|
SubjType string `gorm:"type:VARBINARY(8);default:'';" json:"Type,omitempty" yaml:"Type,omitempty"`
|
||||||
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src,omitempty" yaml:"Src,omitempty"`
|
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src,omitempty" yaml:"Src,omitempty"`
|
||||||
SubjSlug string `gorm:"type:VARBINARY(255);index;default:'';" json:"Slug" yaml:"-"`
|
SubjSlug string `gorm:"type:VARBINARY(160);index;default:'';" json:"Slug" yaml:"-"`
|
||||||
SubjName string `gorm:"type:VARCHAR(255);unique_index;default:'';" json:"Name" yaml:"Name"`
|
SubjName string `gorm:"type:VARCHAR(160);unique_index;default:'';" json:"Name" yaml:"Name"`
|
||||||
SubjAlias string `gorm:"type:VARCHAR(255);default:'';" json:"Alias" yaml:"Alias"`
|
SubjAlias string `gorm:"type:VARCHAR(160);default:'';" json:"Alias" yaml:"Alias"`
|
||||||
SubjBio string `gorm:"type:TEXT;" json:"Bio" yaml:"Bio,omitempty"`
|
SubjBio string `gorm:"type:TEXT;" json:"Bio" yaml:"Bio,omitempty"`
|
||||||
SubjNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
SubjNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
|
||||||
SubjFavorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"`
|
SubjFavorite bool `gorm:"default:false;" json:"Favorite" yaml:"Favorite,omitempty"`
|
||||||
SubjPrivate bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"`
|
SubjPrivate bool `gorm:"default:false;" json:"Private" yaml:"Private,omitempty"`
|
||||||
SubjExcluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"`
|
SubjExcluded bool `gorm:"default:false;" json:"Excluded" yaml:"Excluded,omitempty"`
|
||||||
FileCount int `gorm:"default:0" json:"FileCount" yaml:"-"`
|
FileCount int `gorm:"default:0;" json:"FileCount" yaml:"-"`
|
||||||
Thumb string `gorm:"type:VARBINARY(128);index;default:''" json:"Thumb" yaml:"Thumb,omitempty"`
|
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,omitempty"`
|
||||||
ThumbSrc string `gorm:"type:VARBINARY(8);default:''" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
|
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
|
||||||
MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"`
|
MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"`
|
||||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||||
@@ -56,26 +56,25 @@ func (m *Subject) BeforeCreate(scope *gorm.Scope) error {
|
|||||||
|
|
||||||
// NewSubject returns a new entity.
|
// NewSubject returns a new entity.
|
||||||
func NewSubject(name, subjType, subjSrc string) *Subject {
|
func NewSubject(name, subjType, subjSrc string) *Subject {
|
||||||
|
// Name is required.
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if subjType == "" {
|
if subjType == "" {
|
||||||
subjType = SubjPerson
|
subjType = SubjPerson
|
||||||
}
|
}
|
||||||
|
|
||||||
subjName := txt.Title(txt.Clip(name, txt.ClipDefault))
|
|
||||||
subjSlug := slug.Make(txt.Clip(name, txt.ClipSlug))
|
|
||||||
|
|
||||||
// Name is required.
|
|
||||||
if subjName == "" || subjSlug == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &Subject{
|
result := &Subject{
|
||||||
SubjSlug: subjSlug,
|
|
||||||
SubjName: subjName,
|
|
||||||
SubjType: subjType,
|
SubjType: subjType,
|
||||||
SubjSrc: subjSrc,
|
SubjSrc: subjSrc,
|
||||||
FileCount: 1,
|
FileCount: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := result.SetName(name); err != nil {
|
||||||
|
log.Errorf("subject: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,11 +242,11 @@ func (m *Subject) SetName(name string) error {
|
|||||||
name = txt.NormalizeName(name)
|
name = txt.NormalizeName(name)
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("subject: name must not be empty")
|
return fmt.Errorf("name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.SubjName = name
|
m.SubjName = name
|
||||||
m.SubjSlug = txt.NameSlug(name)
|
m.SubjSlug = txt.Slug(name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -52,10 +52,12 @@ func TestSubject_SetName(t *testing.T) {
|
|||||||
assert.Equal(t, "jens-mander", m.SubjSlug)
|
assert.Equal(t, "jens-mander", m.SubjSlug)
|
||||||
|
|
||||||
err := m.SetName("")
|
err := m.SetName("")
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, "subject: name must not be empty", err.Error())
|
|
||||||
|
assert.Equal(t, "name must not be empty", err.Error())
|
||||||
assert.Equal(t, "Jens Mander", m.SubjName)
|
assert.Equal(t, "Jens Mander", m.SubjName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -43,7 +43,7 @@ func NewFeedback(version, serial string) *Feedback {
|
|||||||
func (c *Config) SendFeedback(f form.Feedback) (err error) {
|
func (c *Config) SendFeedback(f form.Feedback) (err error) {
|
||||||
feedback := NewFeedback(c.Version, c.Serial)
|
feedback := NewFeedback(c.Version, c.Serial)
|
||||||
feedback.Category = f.Category
|
feedback.Category = f.Category
|
||||||
feedback.Subject = txt.TrimLen(f.Message, 50)
|
feedback.Subject = txt.Shorten(f.Message, 50, "...")
|
||||||
feedback.Message = f.Message
|
feedback.Message = f.Message
|
||||||
feedback.UserName = f.UserName
|
feedback.UserName = f.UserName
|
||||||
feedback.UserEmail = f.UserEmail
|
feedback.UserEmail = f.UserEmail
|
||||||
|
@@ -260,21 +260,29 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
|
|||||||
result.Status = IndexSkipped
|
result.Status = IndexSkipped
|
||||||
return result
|
return result
|
||||||
} else if ind.findFaces && file.FilePrimary {
|
} else if ind.findFaces && file.FilePrimary {
|
||||||
faces := ind.Faces(m, photo.PhotoFaces)
|
if markers := file.Markers(); markers != nil {
|
||||||
|
// Detect faces.
|
||||||
|
faces := ind.Faces(m, markers.DetectedFaceCount())
|
||||||
|
|
||||||
if len(faces) > 0 {
|
// Create markers from faces and add them.
|
||||||
file.AddFaces(faces)
|
if len(faces) > 0 {
|
||||||
}
|
file.AddFaces(faces)
|
||||||
|
|
||||||
if c := file.Markers().FaceCount(); photo.PhotoFaces != c {
|
|
||||||
if c > photo.PhotoFaces {
|
|
||||||
extraLabels = append(extraLabels, classify.FaceLabels(faces, entity.SrcImage)...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
photo.PhotoFaces = c
|
// Any new markers?
|
||||||
} else if o.FacesOnly {
|
if file.UnsavedMarkers() {
|
||||||
result.Status = IndexSkipped
|
// Add matching labels.
|
||||||
return result
|
extraLabels = append(extraLabels, file.Markers().Labels()...)
|
||||||
|
} else if o.FacesOnly {
|
||||||
|
// Skip when indexing faces only.
|
||||||
|
result.Status = IndexSkipped
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update photo face count.
|
||||||
|
photo.PhotoFaces = markers.ValidFaceCount()
|
||||||
|
} else {
|
||||||
|
log.Errorf("index: failed loading markers for %s", logName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@ package query
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
@@ -21,7 +22,13 @@ func UpdateAlbumDefaultPreviews() (err error) {
|
|||||||
ORDER BY p.taken_at DESC LIMIT 1
|
ORDER BY p.taken_at DESC LIMIT 1
|
||||||
) WHERE thumb_src='' AND album_type = 'album' AND deleted_at IS NULL`)).Error
|
) WHERE thumb_src='' AND album_type = 'album' AND deleted_at IS NULL`)).Error
|
||||||
|
|
||||||
log.Debugf("previews: updated albums [%s]", time.Since(start))
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated albums [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating albums, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -39,7 +46,13 @@ func UpdateAlbumFolderPreviews() (err error) {
|
|||||||
) WHERE thumb_src = '' AND album_type = 'folder' AND deleted_at IS NULL`)).
|
) WHERE thumb_src = '' AND album_type = 'folder' AND deleted_at IS NULL`)).
|
||||||
Error
|
Error
|
||||||
|
|
||||||
log.Debugf("previews: updated folders [%s]", time.Since(start))
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated folders [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating folders, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -80,7 +93,14 @@ func UpdateAlbumMonthPreviews() (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
log.Debugf("previews: updated calendar [%s]", time.Since(start))
|
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated calendar [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating calendar, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,7 +130,7 @@ func UpdateLabelPreviews() (err error) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Labels.
|
// Labels.
|
||||||
if err = Db().Table(entity.Label{}.TableName()).
|
err = Db().Table(entity.Label{}.TableName()).
|
||||||
UpdateColumn("thumb", gorm.Expr(`(
|
UpdateColumn("thumb", gorm.Expr(`(
|
||||||
SELECT f.file_hash FROM files f
|
SELECT f.file_hash FROM files f
|
||||||
JOIN photos_labels pl ON pl.label_id = labels.id AND pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
JOIN photos_labels pl ON pl.label_id = labels.id AND pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
||||||
@@ -118,13 +138,17 @@ func UpdateLabelPreviews() (err error) {
|
|||||||
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||||
ORDER BY p.photo_quality DESC, pl.uncertainty ASC, p.taken_at DESC LIMIT 1
|
ORDER BY p.photo_quality DESC, pl.uncertainty ASC, p.taken_at DESC LIMIT 1
|
||||||
) WHERE thumb_src = '' AND deleted_at IS NULL`)).
|
) WHERE thumb_src = '' AND deleted_at IS NULL`)).
|
||||||
Error; err != nil {
|
Error
|
||||||
return err
|
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated labels [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating labels, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("previews: updated labels [%s]", time.Since(start))
|
return err
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCategoryPreviews updates category preview images.
|
// UpdateCategoryPreviews updates category preview images.
|
||||||
@@ -132,7 +156,7 @@ func UpdateCategoryPreviews() (err error) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Categories.
|
// Categories.
|
||||||
if err = Db().Table(entity.Label{}.TableName()).
|
err = Db().Table(entity.Label{}.TableName()).
|
||||||
UpdateColumn("thumb", gorm.Expr(`(
|
UpdateColumn("thumb", gorm.Expr(`(
|
||||||
SELECT f.file_hash FROM files f
|
SELECT f.file_hash FROM files f
|
||||||
JOIN photos_labels pl ON pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
JOIN photos_labels pl ON pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
||||||
@@ -141,13 +165,17 @@ func UpdateCategoryPreviews() (err error) {
|
|||||||
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||||
ORDER BY p.photo_quality DESC, pl.uncertainty ASC, p.taken_at DESC LIMIT 1
|
ORDER BY p.photo_quality DESC, pl.uncertainty ASC, p.taken_at DESC LIMIT 1
|
||||||
) WHERE thumb IS NULL AND thumb_src = '' AND deleted_at IS NULL`)).
|
) WHERE thumb IS NULL AND thumb_src = '' AND deleted_at IS NULL`)).
|
||||||
Error; err != nil {
|
Error
|
||||||
return err
|
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated categories [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating categories, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("previews: updated categories [%s]", time.Since(start))
|
return err
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSubjectPreviews updates subject preview images.
|
// UpdateSubjectPreviews updates subject preview images.
|
||||||
@@ -191,7 +219,13 @@ func UpdateSubjectPreviews() (err error) {
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
log.Debugf("previews: updated subjects [%s]", time.Since(start))
|
if err == nil {
|
||||||
|
log.Debugf("previews: updated subjects [%s]", time.Since(start))
|
||||||
|
} else if strings.Contains(err.Error(), "Error 1054") {
|
||||||
|
log.Errorf("previews: failed updating subjects, deprecated or unsupported database")
|
||||||
|
log.Tracef("previews: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@@ -20,7 +20,7 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
if err := f.ParseQueryString(); err != nil {
|
if err := f.ParseQueryString(); err != nil {
|
||||||
return results, 0, err
|
return PhotoResults{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s := UnscopedDb()
|
s := UnscopedDb()
|
||||||
@@ -114,15 +114,15 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
|
|||||||
|
|
||||||
if f.Label != "" {
|
if f.Label != "" {
|
||||||
if err := Db().Where(AnySlug("label_slug", f.Label, txt.Or)).Or(AnySlug("custom_slug", f.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || err != nil {
|
if err := Db().Where(AnySlug("label_slug", f.Label, txt.Or)).Or(AnySlug("custom_slug", f.Label, txt.Or)).Find(&labels).Error; len(labels) == 0 || err != nil {
|
||||||
log.Errorf("search: labels %s not found", txt.Quote(f.Label))
|
log.Debugf("search: label %s not found", txt.QuoteLower(f.Label))
|
||||||
return results, 0, fmt.Errorf("%s not found", txt.Quote(f.Label))
|
return PhotoResults{}, 0, nil
|
||||||
} else {
|
} else {
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
labelIds = append(labelIds, l.ID)
|
labelIds = append(labelIds, l.ID)
|
||||||
|
|
||||||
Db().Where("category_id = ?", l.ID).Find(&categories)
|
Db().Where("category_id = ?", l.ID).Find(&categories)
|
||||||
|
|
||||||
log.Infof("search: label %s includes %d categories", txt.Quote(l.LabelName), len(categories))
|
log.Infof("search: label %s includes %d categories", txt.QuoteLower(l.LabelName), len(categories))
|
||||||
|
|
||||||
for _, category := range categories {
|
for _, category := range categories {
|
||||||
labelIds = append(labelIds, category.LabelID)
|
labelIds = append(labelIds, category.LabelID)
|
||||||
@@ -188,7 +188,7 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
|
|||||||
}
|
}
|
||||||
} else if f.Query != "" {
|
} else if f.Query != "" {
|
||||||
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
|
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
|
||||||
log.Debugf("search: label %s not found, using fuzzy search", txt.Quote(f.Query))
|
log.Debugf("search: label %s not found, using fuzzy search", txt.QuoteLower(f.Query))
|
||||||
|
|
||||||
for _, where := range LikeAnyKeyword("k.keyword", f.Query) {
|
for _, where := range LikeAnyKeyword("k.keyword", f.Query) {
|
||||||
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
|
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
|
||||||
@@ -199,7 +199,7 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
|
|||||||
|
|
||||||
Db().Where("category_id = ?", l.ID).Find(&categories)
|
Db().Where("category_id = ?", l.ID).Find(&categories)
|
||||||
|
|
||||||
log.Debugf("search: label %s includes %d categories", txt.Quote(l.LabelName), len(categories))
|
log.Debugf("search: label %s includes %d categories", txt.QuoteLower(l.LabelName), len(categories))
|
||||||
|
|
||||||
for _, category := range categories {
|
for _, category := range categories {
|
||||||
labelIds = append(labelIds, category.LabelID)
|
labelIds = append(labelIds, category.LabelID)
|
||||||
|
@@ -22,7 +22,7 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
if err := f.ParseQueryString(); err != nil {
|
if err := f.ParseQueryString(); err != nil {
|
||||||
return results, err
|
return GeoResults{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s := UnscopedDb()
|
s := UnscopedDb()
|
||||||
@@ -78,7 +78,7 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
|
|||||||
var labelIds []uint
|
var labelIds []uint
|
||||||
|
|
||||||
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
|
if err := Db().Where(AnySlug("custom_slug", f.Query, " ")).Find(&labels).Error; len(labels) == 0 || err != nil {
|
||||||
log.Debugf("search: label %s not found, using fuzzy search", txt.Quote(f.Query))
|
log.Debugf("search: label %s not found, using fuzzy search", txt.QuoteLower(f.Query))
|
||||||
|
|
||||||
for _, where := range LikeAnyKeyword("k.keyword", f.Query) {
|
for _, where := range LikeAnyKeyword("k.keyword", f.Query) {
|
||||||
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
|
s = s.Where("photos.id IN (SELECT pk.photo_id FROM keywords k JOIN photos_keywords pk ON k.id = pk.keyword_id WHERE (?))", gorm.Expr(where))
|
||||||
@@ -89,7 +89,7 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
|
|||||||
|
|
||||||
Db().Where("category_id = ?", l.ID).Find(&categories)
|
Db().Where("category_id = ?", l.ID).Find(&categories)
|
||||||
|
|
||||||
log.Debugf("search: label %s includes %d categories", txt.Quote(l.LabelName), len(categories))
|
log.Debugf("search: label %s includes %d categories", txt.QuoteLower(l.LabelName), len(categories))
|
||||||
|
|
||||||
for _, category := range categories {
|
for _, category := range categories {
|
||||||
labelIds = append(labelIds, category.LabelID)
|
labelIds = append(labelIds, category.LabelID)
|
||||||
|
@@ -101,10 +101,11 @@ func TestPhotos(t *testing.T) {
|
|||||||
frm.Count = 10
|
frm.Count = 10
|
||||||
frm.Offset = 0
|
frm.Offset = 0
|
||||||
|
|
||||||
photos, _, err := Photos(frm)
|
photos, count, err := Photos(frm)
|
||||||
|
|
||||||
assert.Equal(t, "dog not found", err.Error())
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, photos)
|
assert.Equal(t, PhotoResults{}, photos)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
})
|
})
|
||||||
t.Run("label query landscape", func(t *testing.T) {
|
t.Run("label query landscape", func(t *testing.T) {
|
||||||
var frm form.PhotoSearch
|
var frm form.PhotoSearch
|
||||||
@@ -127,14 +128,11 @@ func TestPhotos(t *testing.T) {
|
|||||||
frm.Count = 10
|
frm.Count = 10
|
||||||
frm.Offset = 0
|
frm.Offset = 0
|
||||||
|
|
||||||
photos, _, err := Photos(frm)
|
photos, count, err := Photos(frm)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, photos)
|
assert.Equal(t, PhotoResults{}, photos)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
if err != nil {
|
|
||||||
assert.Equal(t, err.Error(), "xxx not found")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
t.Run("form.location true", func(t *testing.T) {
|
t.Run("form.location true", func(t *testing.T) {
|
||||||
var frm form.PhotoSearch
|
var frm form.PhotoSearch
|
||||||
|
@@ -1,12 +1,17 @@
|
|||||||
package txt
|
package txt
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ClipDefault = 160
|
Ellipsis = "…"
|
||||||
ClipKeyword = 40
|
ClipKeyword = 40
|
||||||
ClipSlug = 80
|
ClipSlug = 80
|
||||||
ClipVarchar = 255
|
ClipCategory = 100
|
||||||
|
ClipDefault = 160
|
||||||
|
ClipName = 160
|
||||||
|
ClipTitle = 200
|
||||||
ClipQuery = 1000
|
ClipQuery = 1000
|
||||||
ClipDescription = 16000
|
ClipDescription = 16000
|
||||||
)
|
)
|
||||||
@@ -25,13 +30,20 @@ func Clip(s string, size int) string {
|
|||||||
s = string(runes[0 : size-1])
|
s = string(runes[0 : size-1])
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TrimLen(s string, size int) string {
|
// Shorten shortens a string with suffix.
|
||||||
if len(s) < size || size < 4 {
|
func Shorten(s string, size int, suffix string) string {
|
||||||
|
if suffix == "" {
|
||||||
|
suffix = Ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
l := len(suffix)
|
||||||
|
|
||||||
|
if len(s) < size || size < l+1 {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
return Clip(s, size-3) + "..."
|
return Clip(s, size-l) + suffix
|
||||||
}
|
}
|
||||||
|
@@ -7,22 +7,32 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestClip(t *testing.T) {
|
func TestClip(t *testing.T) {
|
||||||
t.Run("clip", func(t *testing.T) {
|
t.Run("ShortEnough", func(t *testing.T) {
|
||||||
assert.Equal(t, "I'm ä", Clip("I'm ä lazy BRoWN fox!", 6))
|
|
||||||
})
|
|
||||||
t.Run("ok", func(t *testing.T) {
|
|
||||||
assert.Equal(t, "I'm ä lazy BRoWN fox!", Clip("I'm ä lazy BRoWN fox!", 128))
|
assert.Equal(t, "I'm ä lazy BRoWN fox!", Clip("I'm ä lazy BRoWN fox!", 128))
|
||||||
})
|
})
|
||||||
t.Run("empty", func(t *testing.T) {
|
t.Run("Clip", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "I'm ä", Clip("I'm ä lazy BRoWN fox!", 6))
|
||||||
|
assert.Equal(t, "I'm ä", Clip("I'm ä lazy BRoWN fox!", 7))
|
||||||
|
})
|
||||||
|
t.Run("TrimSpace", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "abc", Clip(" abc ty3q5y4y46uy", 4))
|
||||||
|
})
|
||||||
|
t.Run("Empty", func(t *testing.T) {
|
||||||
assert.Equal(t, "", Clip("", -1))
|
assert.Equal(t, "", Clip("", -1))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrimLen(t *testing.T) {
|
func TestShorten(t *testing.T) {
|
||||||
t.Run("len < size", func(t *testing.T) {
|
t.Run("ShortEnough", func(t *testing.T) {
|
||||||
assert.Equal(t, "fox!", TrimLen("fox!", 6))
|
assert.Equal(t, "fox!", Shorten("fox!", 6, "..."))
|
||||||
})
|
})
|
||||||
t.Run("len > size", func(t *testing.T) {
|
t.Run("CustomSuffix", func(t *testing.T) {
|
||||||
assert.Equal(t, "I'm ...", TrimLen("I'm ä lazy BRoWN fox!", 8))
|
assert.Equal(t, "I'm...", Shorten("I'm ä lazy BRoWN fox!", 8, "..."))
|
||||||
|
})
|
||||||
|
t.Run("DefaultSuffix", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "I'm…", Shorten("I'm ä lazy BRoWN fox!", 7, ""))
|
||||||
|
})
|
||||||
|
t.Run("Empty", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Shorten("", -1, ""))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -3,8 +3,6 @@ package txt
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gosimple/slug"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UniqueNames removes exact duplicates from a list of strings without changing their order.
|
// UniqueNames removes exact duplicates from a list of strings without changing their order.
|
||||||
@@ -110,22 +108,12 @@ func NormalizeName(name string) string {
|
|||||||
return r
|
return r
|
||||||
}, name)
|
}, name)
|
||||||
|
|
||||||
// Shorten.
|
name = strings.TrimSpace(name)
|
||||||
name = Clip(name, ClipDefault)
|
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capitalize.
|
// Shorten and capitalize.
|
||||||
return Title(name)
|
return Clip(Title(name), ClipDefault)
|
||||||
}
|
|
||||||
|
|
||||||
// NameSlug converts a name to a valid slug.
|
|
||||||
func NameSlug(name string) string {
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return slug.Make(Clip(name, ClipSlug))
|
|
||||||
}
|
}
|
||||||
|
@@ -129,18 +129,3 @@ func TestNormalizeName(t *testing.T) {
|
|||||||
assert.Equal(t, "陈 赵", NormalizeName(" 陈 赵"))
|
assert.Equal(t, "陈 赵", NormalizeName(" 陈 赵"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNameSlug(t *testing.T) {
|
|
||||||
t.Run("Empty", func(t *testing.T) {
|
|
||||||
assert.Equal(t, "", NameSlug(""))
|
|
||||||
})
|
|
||||||
t.Run("BillGates", func(t *testing.T) {
|
|
||||||
assert.Equal(t, "william-henry-gates-iii", NameSlug("William Henry Gates III"))
|
|
||||||
})
|
|
||||||
t.Run("Quotes", func(t *testing.T) {
|
|
||||||
assert.Equal(t, "william-henry-gates", NameSlug("william \"HenRy\" gates' "))
|
|
||||||
})
|
|
||||||
t.Run("Chinese", func(t *testing.T) {
|
|
||||||
assert.Equal(t, "chen-zhao", NameSlug(" 陈 赵"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@@ -13,3 +13,8 @@ func Quote(text string) string {
|
|||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuoteLower converts a string to lowercase and adds quotation marks if needed.
|
||||||
|
func QuoteLower(text string) string {
|
||||||
|
return Quote(strings.ToLower(text))
|
||||||
|
}
|
||||||
|
@@ -17,3 +17,15 @@ func TestQuote(t *testing.T) {
|
|||||||
assert.Equal(t, "“”", Quote(""))
|
assert.Equal(t, "“”", Quote(""))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQuoteLower(t *testing.T) {
|
||||||
|
t.Run("The quick brown fox.", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "“the quick brown fox.”", QuoteLower("The quick brown fox."))
|
||||||
|
})
|
||||||
|
t.Run("filename.txt", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "filename.txt", QuoteLower("filename.txt"))
|
||||||
|
})
|
||||||
|
t.Run("empty string", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "“”", QuoteLower(""))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,21 @@
|
|||||||
package txt
|
package txt
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gosimple/slug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slug converts a string to a valid slug with a max length of 80 runes.
|
||||||
|
func Slug(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return Clip(slug.Make(s), ClipSlug)
|
||||||
|
}
|
||||||
|
|
||||||
// SlugToTitle converts a slug back to a title
|
// SlugToTitle converts a slug back to a title
|
||||||
func SlugToTitle(s string) string {
|
func SlugToTitle(s string) string {
|
||||||
|
@@ -6,6 +6,21 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSlug(t *testing.T) {
|
||||||
|
t.Run("Empty", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Slug(""))
|
||||||
|
})
|
||||||
|
t.Run("BillGates", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "william-henry-gates-iii", Slug("William Henry Gates III"))
|
||||||
|
})
|
||||||
|
t.Run("Quotes", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "william-henry-gates", Slug("william \"HenRy\" gates' "))
|
||||||
|
})
|
||||||
|
t.Run("Chinese", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "chen-zhao", Slug(" 陈 赵"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestSlugToTitle(t *testing.T) {
|
func TestSlugToTitle(t *testing.T) {
|
||||||
t.Run("cute_Kitten", func(t *testing.T) {
|
t.Run("cute_Kitten", func(t *testing.T) {
|
||||||
assert.Equal(t, "Cute-Kitten", SlugToTitle("cute-kitten"))
|
assert.Equal(t, "Cute-Kitten", SlugToTitle("cute-kitten"))
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS alpha;
|
CREATE DATABASE IF NOT EXISTS alpha;
|
||||||
CREATE DATABASE IF NOT EXISTS beta;
|
CREATE DATABASE IF NOT EXISTS beta;
|
||||||
CREATE DATABASE IF NOT EXISTS gamma;
|
CREATE DATABASE IF NOT EXISTS gamma;
|
||||||
CREATE DATABASE IF NOT EXISTS delta;
|
CREATE DATABASE IF NOT EXISTS latest;
|
||||||
CREATE DATABASE IF NOT EXISTS epsilon;
|
CREATE DATABASE IF NOT EXISTS preview;
|
||||||
DROP DATABASE IF EXISTS acceptance;
|
DROP DATABASE IF EXISTS acceptance;
|
||||||
CREATE DATABASE IF NOT EXISTS acceptance;
|
CREATE DATABASE IF NOT EXISTS acceptance;
|
||||||
DROP DATABASE IF EXISTS api;
|
DROP DATABASE IF EXISTS api;
|
||||||
|
Reference in New Issue
Block a user