Backend: Improve resilience #1544

This commit is contained in:
Michael Mayer 2021-09-23 23:46:17 +02:00
parent 1f977e9c0f
commit eb8bc7b709
49 changed files with 703 additions and 339 deletions

64
docker-compose.db.yml Normal file
View 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
View 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
View 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

View file

@ -4,6 +4,7 @@ services:
photoprism:
build: .
image: photoprism/photoprism:develop
container_name: photoprism
depends_on:
- mariadb
- webdav-dummy
@ -65,6 +66,7 @@ services:
mariadb:
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
expose:
- "4001"
@ -81,34 +83,11 @@ services:
webdav-dummy:
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:
go-mod:
driver: local
networks:
default:
name: shared
driver: bridge

View file

@ -188,7 +188,7 @@ export class Rest extends Model {
};
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 offset = 0;
@ -211,8 +211,10 @@ export class Rest extends Model {
resp.limit = limit;
resp.offset = offset;
for (let i = 0; i < resp.data.length; i++) {
resp.models.push(new this(resp.data[i]));
if (count > 0) {
for (let i = 0; i < resp.data.length; i++) {
resp.models.push(new this(resp.data[i]));
}
}
return Promise.resolve(resp);

View file

@ -47,7 +47,7 @@ func RemoveFromFolderCache(rootName string) {
cache.Delete(cacheKey)
if err := query.UpdateAlbumFolderPreviews(); err != nil {
log.Errorf("failed updating folder previews: %s", err)
log.Error(err)
}
log.Debugf("removed %s from cache", cacheKey)
@ -66,7 +66,7 @@ func RemoveFromAlbumCoverCache(uid string) {
}
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()
if err := query.UpdatePreviews(); err != nil {
log.Errorf("failed updating preview images: %s", err)
log.Error(err)
}
log.Debugf("albums: flushed cover cache")

View file

@ -99,7 +99,7 @@ func UpdateSubject(router *gin.RouterGroup) {
return
}
if txt.NameSlug(f.SubjName) == "" {
if txt.Slug(f.SubjName) == "" {
// Return unchanged model data if (normalized) name is empty.
c.JSON(http.StatusOK, m)
return

View file

@ -86,7 +86,7 @@ func main() {
var packageTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT.
package classify
var rules = LabelRules{
var Rules = LabelRules{
{{- range $key, $value := .Rules }}
{{ printf "%q" $key }}: {
Label: {{ printf "%q" $value.Label }},

View file

@ -3,8 +3,6 @@ package classify
import (
"strings"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -31,7 +29,7 @@ func LocationLabel(name string, uncertainty int) Label {
var categories []string
if rule, ok := rules.Find(name); ok {
if rule, ok := Rules.Find(name); ok {
priority = rule.Priority
categories = rule.Categories
}
@ -49,26 +47,3 @@ func LocationLabel(name string, uncertainty int) Label {
func (l Label) Title() string {
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,
}}
}

View file

@ -3,8 +3,6 @@ package classify
import (
"testing"
"github.com/photoprism/photoprism/internal/face"
"github.com/stretchr/testify/assert"
)
@ -53,50 +51,3 @@ func TestLabel_Title(t *testing.T) {
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])
})
}

View file

@ -1,7 +1,7 @@
// Code generated by go generate; DO NOT EDIT.
package classify
var rules = LabelRules{
var Rules = LabelRules{
"abacus": {
Label: "",
Threshold: 1.000000,

View file

@ -7,7 +7,7 @@ import (
)
func TestLabelRules_Find(t *testing.T) {
result, ok := rules.Find("cat")
result, ok := Rules.Find("cat")
assert.True(t, ok)
assert.Equal(t, "cat", result.Label)
assert.Equal(t, "animal", result.Categories[0])

View file

@ -180,7 +180,7 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
labelText := strings.ToLower(t.labels[i])
rule, _ := rules.Find(labelText)
rule, _ := Rules.Find(labelText)
// discard labels that don't met the threshold
if p < rule.Threshold {

View file

@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/remote"
"github.com/photoprism/photoprism/internal/remote/webdav"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/ulule/deepcopier"
)
@ -24,8 +25,8 @@ type Accounts []Account
// Account represents a remote service account for uploading, downloading or syncing media files.
type Account struct {
ID uint `gorm:"primary_key"`
AccName string `gorm:"type:VARCHAR(255);"`
AccOwner string `gorm:"type:VARCHAR(255);"`
AccName string `gorm:"type:VARCHAR(160);"`
AccOwner string `gorm:"type:VARCHAR(160);"`
AccURL string `gorm:"type:VARBINARY(512);"`
AccType 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
}
// 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 {
db := Db()
@ -94,6 +95,9 @@ func (m *Account) SaveForm(form form.Account) error {
m.SyncStatus = AccountSyncStatusRefresh
}
m.AccName = txt.Clip(m.AccName, txt.ClipName)
m.AccOwner = txt.Clip(m.AccOwner, txt.ClipName)
return db.Save(m).Error
}
@ -119,7 +123,7 @@ func (m *Account) Updates(values interface{}) 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 {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}

View file

@ -31,12 +31,12 @@ type Album struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
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"`
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"`
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
AlbumTitle string `gorm:"type:VARCHAR(255);index;" json:"Title" yaml:"Title"`
AlbumLocation string `gorm:"type:VARCHAR(255);" json:"Location" yaml:"Location,omitempty"`
AlbumCategory string `gorm:"type:VARCHAR(255);index;" json:"Category" yaml:"Category,omitempty"`
AlbumTitle string `gorm:"type:VARCHAR(160);index;" json:"Title" yaml:"Title"`
AlbumLocation string `gorm:"type:VARCHAR(160);" json:"Location" yaml:"Location,omitempty"`
AlbumCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"`
AlbumCaption string `gorm:"type:TEXT;" json:"Caption" yaml:"Caption,omitempty"`
AlbumDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"`
AlbumNotes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,omitempty"`
@ -292,7 +292,7 @@ func (m *Album) String() string {
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 {
return m.AlbumType == AlbumMoment
}
@ -309,9 +309,9 @@ func (m *Album) SetTitle(title string) {
if m.AlbumType == AlbumDefault {
if len(m.AlbumTitle) < txt.ClipSlug {
m.AlbumSlug = slug.Make(m.AlbumTitle)
m.AlbumSlug = txt.Slug(m.AlbumTitle)
} 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 != "" {
m.AlbumCategory = txt.Title(txt.Clip(f.AlbumCategory, txt.ClipKeyword))
m.AlbumCategory = txt.Clip(txt.Title(f.AlbumCategory), txt.ClipCategory)
}
if f.AlbumTitle != "" {

View file

@ -5,7 +5,6 @@ import (
"sync"
"time"
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -18,11 +17,11 @@ type Cameras []Camera
// Camera model and make (as extracted from UpdateExif metadata)
type Camera struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
CameraSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
CameraName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
CameraMake string `gorm:"type:VARCHAR(255);" json:"Make" yaml:"Make,omitempty"`
CameraModel string `gorm:"type:VARCHAR(255);" json:"Model" yaml:"Model,omitempty"`
CameraType string `gorm:"type:VARCHAR(255);" json:"Type,omitempty" yaml:"Type,omitempty"`
CameraSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
CameraName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
CameraMake string `gorm:"type:VARCHAR(160);" json:"Make" yaml:"Make,omitempty"`
CameraModel string `gorm:"type:VARCHAR(160);" json:"Model" yaml:"Model,omitempty"`
CameraType string `gorm:"type:VARCHAR(100);" json:"Type,omitempty" yaml:"Type,omitempty"`
CameraDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
CameraNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
CreatedAt time.Time `json:"-" yaml:"-"`
@ -44,8 +43,8 @@ func CreateUnknownCamera() {
// NewCamera creates a camera entity from a model name and a make name.
func NewCamera(modelName string, makeName string) *Camera {
modelName = txt.Clip(modelName, txt.ClipDefault)
makeName = txt.Clip(makeName, txt.ClipDefault)
modelName = strings.TrimSpace(modelName)
makeName = strings.TrimSpace(makeName)
if modelName == "" && makeName == "" {
return &UnknownCamera
@ -72,13 +71,12 @@ func NewCamera(modelName string, makeName string) *Camera {
}
cameraName := strings.Join(name, " ")
cameraSlug := slug.Make(txt.Clip(cameraName, txt.ClipSlug))
result := &Camera{
CameraSlug: cameraSlug,
CameraName: cameraName,
CameraMake: makeName,
CameraModel: modelName,
CameraSlug: txt.Slug(cameraName),
CameraName: txt.Clip(cameraName, txt.ClipName),
CameraMake: txt.Clip(makeName, txt.ClipName),
CameraModel: txt.Clip(modelName, txt.ClipName),
}
return result

View file

@ -1,10 +1,10 @@
package entity
import (
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/pkg/txt"
)
// 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.
type Country struct {
ID string `gorm:"type:VARBINARY(2);primary_key" json:"ID" yaml:"ID"`
CountrySlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
CountryName string `json:"Name" yaml:"Name,omitempty"`
CountrySlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
CountryName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name,omitempty"`
CountryDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
CountryNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
CountryPhoto *Photo `json:"-" yaml:"-"`
@ -51,12 +51,10 @@ func NewCountry(countryCode string, countryName string) *Country {
countryName = altName
}
countrySlug := slug.MakeLang(countryName, "en")
result := &Country{
ID: countryCode,
CountryName: countryName,
CountrySlug: countrySlug,
CountryName: txt.Clip(countryName, txt.ClipName),
CountrySlug: txt.Slug(countryName),
}
return result

View file

@ -10,6 +10,9 @@ import (
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.
type Details struct {
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"`
Notes string `gorm:"type:TEXT;" json:"Notes" yaml:"Notes,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"`
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"`
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"`
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"`
CreatedAt time.Time `yaml:"-"`
UpdatedAt time.Time `yaml:"-"`
@ -160,7 +163,7 @@ func (m *Details) SetKeywords(data, src string) {
// SetSubject updates the photo details field.
func (m *Details) SetSubject(data, src string) {
val := txt.Clip(data, txt.ClipVarchar)
val := txt.Clip(data, ClipDetail)
if val == "" {
return
@ -192,7 +195,7 @@ func (m *Details) SetNotes(data, src string) {
// SetArtist updates the photo details field.
func (m *Details) SetArtist(data, src string) {
val := txt.Clip(data, txt.ClipVarchar)
val := txt.Clip(data, ClipDetail)
if val == "" {
return
@ -208,7 +211,7 @@ func (m *Details) SetArtist(data, src string) {
// SetCopyright updates the photo details field.
func (m *Details) SetCopyright(data, src string) {
val := txt.Clip(data, txt.ClipVarchar)
val := txt.Clip(data, ClipDetail)
if val == "" {
return
@ -224,7 +227,7 @@ func (m *Details) SetCopyright(data, src string) {
// SetLicense updates the photo details field.
func (m *Details) SetLicense(data, src string) {
val := txt.Clip(data, txt.ClipVarchar)
val := txt.Clip(data, ClipDetail)
if val == "" {
return

View file

@ -13,6 +13,8 @@ import (
"fmt"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
)
@ -71,10 +73,10 @@ func (list Types) WaitForMigration() {
for i := 0; i <= attempts; i++ {
count := RowCount{}
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
} 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 {
@ -93,20 +95,21 @@ func (list Types) Truncate() {
// log.Debugf("entity: removed all data from %s", name)
break
} 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.
func (list Types) Migrate() {
for _, entity := range list {
for name, entity := range list {
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)
if err := UnscopedDb().AutoMigrate(entity).Error; err != nil {
log.Errorf("entity: failed migrating %s", txt.Quote(name))
panic(err)
}
}

View file

@ -351,8 +351,8 @@ func FindFace(id string) *Face {
return &f
}
// FaceCount counts the number of valid face markers for a file uid.
func FaceCount(fileUID string) (c int) {
// ValidFaceCount counts the number of valid face markers for a file uid.
func ValidFaceCount(fileUID string) (c int) {
if !rnd.IsPPID(fileUID, 'f') {
return
}

View file

@ -445,9 +445,9 @@ func (m *File) AddFace(f face.Face, subjUID string) {
}
}
// FaceCount returns the current number of valid faces detected.
func (m *File) FaceCount() (c int) {
return FaceCount(m.FileUID)
// ValidFaceCount returns the number of valid face markers.
func (m *File) ValidFaceCount() (c int) {
return ValidFaceCount(m.FileUID)
}
// 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
}
c = m.FaceCount()
c = m.ValidFaceCount()
err = UnscopedDb().Model(Photo{}).
Where("id = ?", m.PhotoID).
@ -491,6 +491,15 @@ func (m *File) Markers() *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.
func (m *File) SubjectNames() []string {
return m.Markers().SubjectNames()

View file

@ -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) {
file := FileFixturesExampleBridge
result := file.FaceCount()
result := file.ValidFaceCount()
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())
})
}

View file

@ -7,7 +7,6 @@ import (
"sync"
"time"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/form"
"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"`
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"`
FolderTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title,omitempty"`
FolderCategory string `gorm:"type:VARCHAR(255);index;" json:"Category" yaml:"Category,omitempty"`
FolderTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title,omitempty"`
FolderCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"`
FolderDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,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"`
@ -130,13 +129,13 @@ func (m *Folder) SetValuesFromPath() {
}
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.
func (m *Folder) Slug() string {
return slug.Make(m.Path)
return txt.Slug(m.Path)
}
// RootPath returns the full folder path including root.
@ -144,12 +143,12 @@ func (m *Folder) RootPath() string {
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 {
return m.FolderTitle
}
// Saves the complete entity in the database.
// Create inserts the entity to the index.
func (m *Folder) Create() error {
folderMutex.Lock()
defer folderMutex.Unlock()
@ -232,5 +231,8 @@ func (m *Folder) SetForm(f form.Folder) error {
return err
}
m.FolderTitle = txt.Clip(m.FolderTitle, txt.ClipTitle)
m.FolderCategory = txt.Clip(m.FolderCategory, txt.ClipCategory)
return nil
}

View file

@ -4,7 +4,6 @@ import (
"sync"
"time"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/event"
@ -20,9 +19,9 @@ type Labels []Label
type Label struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
LabelSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"-"`
CustomSlug string `gorm:"type:VARBINARY(255);index;" json:"CustomSlug" yaml:"-"`
LabelName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
LabelFavorite bool `json:"Favorite" yaml:"Favorite,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)
labelSlug := slug.Make(txt.Clip(labelName, txt.ClipSlug))
labelSlug := txt.Slug(labelName)
result := &Label{
LabelSlug: labelSlug,
CustomSlug: labelSlug,
LabelName: labelName,
LabelName: txt.Clip(labelName, txt.ClipName),
LabelPriority: priority,
PhotoCount: 1,
}
@ -142,7 +141,7 @@ func FirstOrCreateLabel(m *Label) *Label {
// FindLabel returns an existing row if exists.
func FindLabel(s string) *Label {
labelSlug := slug.Make(txt.Clip(s, txt.ClipSlug))
labelSlug := txt.Slug(s)
result := Label{}
@ -167,8 +166,8 @@ func (m *Label) SetName(name string) {
return
}
m.LabelName = name
m.CustomSlug = txt.NameSlug(name)
m.LabelName = txt.Clip(name, txt.ClipName)
m.CustomSlug = txt.Slug(name)
}
// UpdateClassify updates a label if necessary

View file

@ -5,7 +5,6 @@ import (
"sync"
"time"
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -18,11 +17,11 @@ type Lenses []Lens
// Lens represents camera lens (as extracted from UpdateExif metadata)
type Lens struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"ID"`
LensSlug string `gorm:"type:VARBINARY(255);unique_index;" json:"Slug" yaml:"Slug,omitempty"`
LensName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name"`
LensMake string `gorm:"type:VARCHAR(255);" json:"Make" yaml:"Make,omitempty"`
LensModel string `gorm:"type:VARCHAR(255);" json:"Model" yaml:"Model,omitempty"`
LensType string `gorm:"type:VARCHAR(255);" json:"Type" yaml:"Type,omitempty"`
LensSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"Slug,omitempty"`
LensName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
LensMake string `gorm:"type:VARCHAR(160);" json:"Make" yaml:"Make,omitempty"`
LensModel string `gorm:"type:VARCHAR(160);" json:"Model" yaml:"Model,omitempty"`
LensType string `gorm:"type:VARCHAR(100);" json:"Type" yaml:"Type,omitempty"`
LensDescription string `gorm:"type:TEXT;" json:"Description,omitempty" yaml:"Description,omitempty"`
LensNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
CreatedAt time.Time `json:"-" yaml:"-"`
@ -49,8 +48,8 @@ func (Lens) TableName() string {
// NewLens creates a new lens in database
func NewLens(modelName string, makeName string) *Lens {
modelName = txt.Clip(modelName, txt.ClipDefault)
makeName = txt.Clip(makeName, txt.ClipDefault)
modelName = strings.TrimSpace(modelName)
makeName = strings.TrimSpace(makeName)
if modelName == "" && makeName == "" {
return &UnknownLens
@ -73,13 +72,12 @@ func NewLens(modelName string, makeName string) *Lens {
}
lensName := strings.Join(name, " ")
lensSlug := slug.Make(txt.Clip(lensName, txt.ClipSlug))
result := &Lens{
LensSlug: lensSlug,
LensName: lensName,
LensMake: makeName,
LensModel: modelName,
LensSlug: txt.Slug(lensName),
LensName: txt.Clip(lensName, txt.ClipName),
LensMake: txt.Clip(makeName, txt.ClipName),
LensModel: txt.Clip(modelName, txt.ClipName),
}
return result

View file

@ -4,7 +4,6 @@ import (
"fmt"
"time"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/rnd"
@ -17,8 +16,8 @@ type Links []Link
type Link struct {
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"`
ShareSlug string `gorm:"type:VARBINARY(255);index;" json:"Slug" yaml:"Slug,omitempty"`
LinkToken string `gorm:"type:VARBINARY(255);unique_index:idx_links_uid_token;" json:"Token" yaml:"Token,omitempty"`
ShareSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug,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"`
LinkViews uint `json:"Views" yaml:"-"`
MaxViews uint `json:"MaxViews" yaml:"-"`
@ -81,7 +80,7 @@ func (m *Link) Expired() bool {
}
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 {

View file

@ -29,7 +29,7 @@ type Marker struct {
FileUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"FileUID" yaml:"FileUID"`
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
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"`
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,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))
}
// 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.
func FindMarker(markerUid string) *Marker {
if markerUid == "" {

View file

@ -1,6 +1,7 @@
package entity
import (
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -26,6 +27,17 @@ func (m Markers) Save(file *File) (count int, err error) {
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.
func (m Markers) Contains(other Marker) bool {
for _, marker := range m {
@ -37,15 +49,26 @@ func (m Markers) Contains(other Marker) bool {
return false
}
// FaceCount returns the number of valid face markers.
func (m Markers) FaceCount() (faces int) {
// DetectedFaceCount returns the number of automatically detected face markers.
func (m Markers) DetectedFaceCount() (count int) {
for _, marker := range m {
if !marker.MarkerInvalid && marker.MarkerType == MarkerFace {
faces++
if marker.DetectedFace() {
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.
@ -61,6 +84,48 @@ func (m Markers) SubjectNames() (names []string) {
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.
func (m *Markers) Append(marker Marker) {
*m = append(*m, marker)

View file

@ -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)
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 100, 65)
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", 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.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) {
@ -85,3 +96,63 @@ func TestMarkers_SubjectNames(t *testing.T) {
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)
}
})
}

View file

@ -49,7 +49,7 @@ type Photo struct {
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"`
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"`
PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,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:"-"`
PhotoColor uint8 `json:"Color" 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:"-"`
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"`
@ -1073,8 +1073,8 @@ func (m *Photo) MapKey() string {
// SetCameraSerial updates the camera serial number.
func (m *Photo) SetCameraSerial(s string) {
if val := txt.Clip(s, txt.ClipVarchar); m.NoCameraSerial() && val != "" {
m.CameraSerial = val
if s = txt.Clip(s, txt.ClipDefault); m.NoCameraSerial() && s != "" {
m.CameraSerial = s
}
}
@ -1083,6 +1083,6 @@ func (m *Photo) FaceCount() int {
if f, err := m.PrimaryFile(); err != nil {
return 0
} else {
return f.FaceCount()
return f.ValidFaceCount()
}
}

View file

@ -2,6 +2,7 @@ package entity
import (
"fmt"
"strings"
"time"
"github.com/jinzhu/gorm"
@ -146,10 +147,22 @@ func UpdateLabelPhotoCounts() (err error) {
// UpdatePhotoCounts updates precalculated photo and file counts.
func UpdatePhotoCounts() (err error) {
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
}
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
}

View file

@ -22,9 +22,9 @@ func (m *Photo) NoTitle() bool {
// SetTitle changes the photo title and clips it to 300 characters.
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
}
@ -32,7 +32,7 @@ func (m *Photo) SetTitle(title, source string) {
return
}
m.PhotoTitle = newTitle
m.PhotoTitle = title
m.TitleSrc = source
}

View file

@ -3,10 +3,10 @@ package entity
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"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"`
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"`
SubjSlug string `gorm:"type:VARBINARY(255);index;default:'';" json:"Slug" yaml:"-"`
SubjName string `gorm:"type:VARCHAR(255);unique_index;default:'';" json:"Name" yaml:"Name"`
SubjAlias string `gorm:"type:VARCHAR(255);default:'';" json:"Alias" yaml:"Alias"`
SubjSlug string `gorm:"type:VARBINARY(160);index;default:'';" json:"Slug" yaml:"-"`
SubjName string `gorm:"type:VARCHAR(160);unique_index;default:'';" json:"Name" yaml:"Name"`
SubjAlias string `gorm:"type:VARCHAR(160);default:'';" json:"Alias" yaml:"Alias"`
SubjBio string `gorm:"type:TEXT;" json:"Bio" yaml:"Bio,omitempty"`
SubjNotes string `gorm:"type:TEXT;" json:"Notes,omitempty" yaml:"Notes,omitempty"`
SubjFavorite bool `gorm:"default:false" json:"Favorite" yaml:"Favorite,omitempty"`
SubjPrivate bool `gorm:"default:false" json:"Private" yaml:"Private,omitempty"`
SubjExcluded bool `gorm:"default:false" json:"Excluded" yaml:"Excluded,omitempty"`
FileCount int `gorm:"default:0" json:"FileCount" yaml:"-"`
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"`
SubjFavorite bool `gorm:"default:false;" json:"Favorite" yaml:"Favorite,omitempty"`
SubjPrivate bool `gorm:"default:false;" json:"Private" yaml:"Private,omitempty"`
SubjExcluded bool `gorm:"default:false;" json:"Excluded" yaml:"Excluded,omitempty"`
FileCount int `gorm:"default:0;" json:"FileCount" yaml:"-"`
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"`
MetadataJSON json.RawMessage `gorm:"type:MEDIUMBLOB;" json:"Metadata,omitempty" yaml:"Metadata,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
@ -56,26 +56,25 @@ func (m *Subject) BeforeCreate(scope *gorm.Scope) error {
// NewSubject returns a new entity.
func NewSubject(name, subjType, subjSrc string) *Subject {
// Name is required.
if strings.TrimSpace(name) == "" {
return nil
}
if subjType == "" {
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{
SubjSlug: subjSlug,
SubjName: subjName,
SubjType: subjType,
SubjSrc: subjSrc,
FileCount: 1,
}
if err := result.SetName(name); err != nil {
log.Errorf("subject: %s", err)
}
return result
}
@ -243,11 +242,11 @@ func (m *Subject) SetName(name string) error {
name = txt.NormalizeName(name)
if name == "" {
return fmt.Errorf("subject: name must not be empty")
return fmt.Errorf("name must not be empty")
}
m.SubjName = name
m.SubjSlug = txt.NameSlug(name)
m.SubjSlug = txt.Slug(name)
return nil
}

View file

@ -52,10 +52,12 @@ func TestSubject_SetName(t *testing.T) {
assert.Equal(t, "jens-mander", m.SubjSlug)
err := m.SetName("")
if err == nil {
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)
})
}

View file

@ -43,7 +43,7 @@ func NewFeedback(version, serial string) *Feedback {
func (c *Config) SendFeedback(f form.Feedback) (err error) {
feedback := NewFeedback(c.Version, c.Serial)
feedback.Category = f.Category
feedback.Subject = txt.TrimLen(f.Message, 50)
feedback.Subject = txt.Shorten(f.Message, 50, "...")
feedback.Message = f.Message
feedback.UserName = f.UserName
feedback.UserEmail = f.UserEmail

View file

@ -260,21 +260,29 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
result.Status = IndexSkipped
return result
} 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 {
file.AddFaces(faces)
}
if c := file.Markers().FaceCount(); photo.PhotoFaces != c {
if c > photo.PhotoFaces {
extraLabels = append(extraLabels, classify.FaceLabels(faces, entity.SrcImage)...)
// Create markers from faces and add them.
if len(faces) > 0 {
file.AddFaces(faces)
}
photo.PhotoFaces = c
} else if o.FacesOnly {
result.Status = IndexSkipped
return result
// Any new markers?
if file.UnsavedMarkers() {
// Add matching labels.
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)
}
}

View file

@ -2,6 +2,7 @@ package query
import (
"fmt"
"strings"
"time"
"github.com/jinzhu/gorm"
@ -21,7 +22,13 @@ func UpdateAlbumDefaultPreviews() (err error) {
ORDER BY p.taken_at DESC LIMIT 1
) 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
}
@ -39,7 +46,13 @@ func UpdateAlbumFolderPreviews() (err error) {
) WHERE thumb_src = '' AND album_type = 'folder' AND deleted_at IS NULL`)).
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
}
@ -80,7 +93,14 @@ func UpdateAlbumMonthPreviews() (err error) {
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
}
@ -110,7 +130,7 @@ func UpdateLabelPreviews() (err error) {
start := time.Now()
// Labels.
if err = Db().Table(entity.Label{}.TableName()).
err = Db().Table(entity.Label{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
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
@ -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'
ORDER BY p.photo_quality DESC, pl.uncertainty ASC, p.taken_at DESC LIMIT 1
) WHERE thumb_src = '' AND deleted_at IS NULL`)).
Error; err != nil {
return err
Error
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 nil
return err
}
// UpdateCategoryPreviews updates category preview images.
@ -132,7 +156,7 @@ func UpdateCategoryPreviews() (err error) {
start := time.Now()
// Categories.
if err = Db().Table(entity.Label{}.TableName()).
err = Db().Table(entity.Label{}.TableName()).
UpdateColumn("thumb", gorm.Expr(`(
SELECT f.file_hash FROM files f
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'
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`)).
Error; err != nil {
return err
Error
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 nil
return err
}
// 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
}

View file

@ -20,7 +20,7 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
start := time.Now()
if err := f.ParseQueryString(); err != nil {
return results, 0, err
return PhotoResults{}, 0, err
}
s := UnscopedDb()
@ -114,15 +114,15 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
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 {
log.Errorf("search: labels %s not found", txt.Quote(f.Label))
return results, 0, fmt.Errorf("%s not found", txt.Quote(f.Label))
log.Debugf("search: label %s not found", txt.QuoteLower(f.Label))
return PhotoResults{}, 0, nil
} else {
for _, l := range labels {
labelIds = append(labelIds, l.ID)
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 {
labelIds = append(labelIds, category.LabelID)
@ -188,7 +188,7 @@ func Photos(f form.PhotoSearch) (results PhotoResults, count int, err error) {
}
} else if f.Query != "" {
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) {
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)
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 {
labelIds = append(labelIds, category.LabelID)

View file

@ -22,7 +22,7 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
start := time.Now()
if err := f.ParseQueryString(); err != nil {
return results, err
return GeoResults{}, err
}
s := UnscopedDb()
@ -78,7 +78,7 @@ func PhotosGeo(f form.PhotoSearchGeo) (results GeoResults, err error) {
var labelIds []uint
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) {
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)
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 {
labelIds = append(labelIds, category.LabelID)

View file

@ -101,10 +101,11 @@ func TestPhotos(t *testing.T) {
frm.Count = 10
frm.Offset = 0
photos, _, err := Photos(frm)
photos, count, err := Photos(frm)
assert.Equal(t, "dog not found", err.Error())
assert.Empty(t, photos)
assert.NoError(t, err)
assert.Equal(t, PhotoResults{}, photos)
assert.Equal(t, 0, count)
})
t.Run("label query landscape", func(t *testing.T) {
var frm form.PhotoSearch
@ -127,14 +128,11 @@ func TestPhotos(t *testing.T) {
frm.Count = 10
frm.Offset = 0
photos, _, err := Photos(frm)
photos, count, err := Photos(frm)
assert.Error(t, err)
assert.Empty(t, photos)
if err != nil {
assert.Equal(t, err.Error(), "xxx not found")
}
assert.NoError(t, err)
assert.Equal(t, PhotoResults{}, photos)
assert.Equal(t, 0, count)
})
t.Run("form.location true", func(t *testing.T) {
var frm form.PhotoSearch

View file

@ -1,12 +1,17 @@
package txt
import "strings"
import (
"strings"
)
const (
ClipDefault = 160
Ellipsis = "…"
ClipKeyword = 40
ClipSlug = 80
ClipVarchar = 255
ClipCategory = 100
ClipDefault = 160
ClipName = 160
ClipTitle = 200
ClipQuery = 1000
ClipDescription = 16000
)
@ -25,13 +30,20 @@ func Clip(s string, size int) string {
s = string(runes[0 : size-1])
}
return s
return strings.TrimSpace(s)
}
func TrimLen(s string, size int) string {
if len(s) < size || size < 4 {
// Shorten shortens a string with suffix.
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 Clip(s, size-3) + "..."
return Clip(s, size-l) + suffix
}

View file

@ -7,22 +7,32 @@ import (
)
func TestClip(t *testing.T) {
t.Run("clip", func(t *testing.T) {
assert.Equal(t, "I'm ä", Clip("I'm ä lazy BRoWN fox!", 6))
})
t.Run("ok", func(t *testing.T) {
t.Run("ShortEnough", func(t *testing.T) {
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))
})
}
func TestTrimLen(t *testing.T) {
t.Run("len < size", func(t *testing.T) {
assert.Equal(t, "fox!", TrimLen("fox!", 6))
func TestShorten(t *testing.T) {
t.Run("ShortEnough", func(t *testing.T) {
assert.Equal(t, "fox!", Shorten("fox!", 6, "..."))
})
t.Run("len > size", func(t *testing.T) {
assert.Equal(t, "I'm ...", TrimLen("I'm ä lazy BRoWN fox!", 8))
t.Run("CustomSuffix", func(t *testing.T) {
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, ""))
})
}

View file

@ -3,8 +3,6 @@ package txt
import (
"fmt"
"strings"
"github.com/gosimple/slug"
)
// UniqueNames removes exact duplicates from a list of strings without changing their order.
@ -110,22 +108,12 @@ func NormalizeName(name string) string {
return r
}, name)
// Shorten.
name = Clip(name, ClipDefault)
name = strings.TrimSpace(name)
if name == "" {
return ""
}
// Capitalize.
return Title(name)
}
// NameSlug converts a name to a valid slug.
func NameSlug(name string) string {
if name == "" {
return ""
}
return slug.Make(Clip(name, ClipSlug))
// Shorten and capitalize.
return Clip(Title(name), ClipDefault)
}

View file

@ -129,18 +129,3 @@ func TestNormalizeName(t *testing.T) {
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(" 陈 赵"))
})
}

View file

@ -13,3 +13,8 @@ func Quote(text string) string {
return text
}
// QuoteLower converts a string to lowercase and adds quotation marks if needed.
func QuoteLower(text string) string {
return Quote(strings.ToLower(text))
}

View file

@ -17,3 +17,15 @@ func TestQuote(t *testing.T) {
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(""))
})
}

View file

@ -1,6 +1,21 @@
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
func SlugToTitle(s string) string {

View file

@ -6,6 +6,21 @@ import (
"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) {
t.Run("cute_Kitten", func(t *testing.T) {
assert.Equal(t, "Cute-Kitten", SlugToTitle("cute-kitten"))

View file

@ -1,8 +1,8 @@
CREATE DATABASE IF NOT EXISTS alpha;
CREATE DATABASE IF NOT EXISTS beta;
CREATE DATABASE IF NOT EXISTS gamma;
CREATE DATABASE IF NOT EXISTS delta;
CREATE DATABASE IF NOT EXISTS epsilon;
CREATE DATABASE IF NOT EXISTS latest;
CREATE DATABASE IF NOT EXISTS preview;
DROP DATABASE IF EXISTS acceptance;
CREATE DATABASE IF NOT EXISTS acceptance;
DROP DATABASE IF EXISTS api;