Search: Add landscape/square filters, and "show filters" command #2169

This commit is contained in:
Michael Mayer 2022-04-13 09:48:51 +02:00
parent 0427163295
commit 7291c1d703
17 changed files with 415 additions and 116 deletions

View file

@ -10,6 +10,7 @@ var ShowCommand = cli.Command{
Usage: "Configuration and system report subcommands",
Subcommands: []cli.Command{
ShowConfigCommand,
ShowFiltersCommand,
ShowFormatsCommand,
},
}

View file

@ -12,7 +12,7 @@ import (
var ShowConfigCommand = cli.Command{
Name: "config",
Usage: "Displays global configuration values",
Usage: "Shows global configuration values",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "no-wrap, n",

View file

@ -1,32 +0,0 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/capture"
)
func TestConfigCommand(t *testing.T) {
var err error
ctx := config.CliTestContext()
output := capture.Output(func() {
err = ShowConfigCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
// Expected config command output.
assert.Contains(t, output, "config-file")
assert.Contains(t, output, "darktable-cli")
assert.Contains(t, output, "originals-path")
assert.Contains(t, output, "import-path")
assert.Contains(t, output, "cache-path")
assert.Contains(t, output, "assets-path")
}

View file

@ -0,0 +1,40 @@
package commands
import (
"fmt"
"sort"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/report"
)
var ShowFiltersCommand = cli.Command{
Name: "filters",
Usage: "Displays a search filter overview with examples",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "no-wrap, n",
Usage: "disable text-wrapping so the output can be pasted into Markdown files",
},
},
Action: showFiltersAction,
}
// showFiltersAction lists supported search filters.
func showFiltersAction(ctx *cli.Context) error {
rows, cols := form.Table(&form.SearchPhotos{})
sort.Slice(rows, func(i, j int) bool {
if rows[i][1] == rows[j][1] {
return rows[i][0] < rows[j][0]
} else {
return rows[i][1] < rows[j][1]
}
})
fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap")))
return nil
}

View file

@ -11,7 +11,7 @@ import (
var ShowFormatsCommand = cli.Command{
Name: "formats",
Usage: "Displays supported media and sidecar file formats",
Usage: "Lists supported media and sidecar file formats",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "compact, c",

View file

@ -0,0 +1,74 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/capture"
)
func TestShowConfigCommand(t *testing.T) {
var err error
ctx := config.CliTestContext()
output := capture.Output(func() {
err = ShowConfigCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
// Expected config command output.
assert.Contains(t, output, "config-file")
assert.Contains(t, output, "darktable-cli")
assert.Contains(t, output, "originals-path")
assert.Contains(t, output, "import-path")
assert.Contains(t, output, "cache-path")
assert.Contains(t, output, "assets-path")
}
func TestShowFiltersCommand(t *testing.T) {
var err error
ctx := config.CliTestContext()
output := capture.Output(func() {
err = ShowFiltersCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
// Expected config command output.
assert.Contains(t, output, "landscape")
assert.Contains(t, output, "live")
assert.Contains(t, output, "Examples")
assert.Contains(t, output, "Filter")
assert.Contains(t, output, "Notes")
}
func TestShowFormatsCommand(t *testing.T) {
var err error
ctx := config.CliTestContext()
output := capture.Output(func() {
err = ShowFormatsCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
// Expected config command output.
assert.Contains(t, output, "JPEG")
assert.Contains(t, output, "MP4")
assert.Contains(t, output, "Image")
assert.Contains(t, output, "Format")
assert.Contains(t, output, "Description")
}

View file

@ -133,7 +133,7 @@ var FileFixtures = FileMap{
FileHeight: 0,
FileOrientation: 0,
FileProjection: "",
FileAspectRatio: 0,
FileAspectRatio: 1,
FileMainColor: "",
FileColors: "",
FileLuminance: "",
@ -1562,11 +1562,11 @@ var FileFixtures = FileMap{
FileMissing: false,
FilePortrait: false,
FileDuration: 0,
FileWidth: 640,
FileHeight: 1136,
FileWidth: 16000,
FileHeight: 16000,
FileOrientation: 1,
FileProjection: "",
FileAspectRatio: 0.56,
FileAspectRatio: 1,
FileMainColor: "grey",
FileColors: "141101110",
FileLuminance: "BD9A22751",

View file

@ -22,6 +22,9 @@ type SearchGeo struct {
Live bool `form:"live"`
Scan bool `form:"scan"`
Panorama bool `form:"panorama"`
Portrait bool `form:"portrait"`
Landscape bool `form:"landscape"`
Square bool `form:"square"`
Archived bool `form:"archived"`
Public bool `form:"public"`
Private bool `form:"private"`

View file

@ -79,6 +79,25 @@ func TestGeoSearch(t *testing.T) {
assert.Equal(t, uint(0x61a8), form.Dist)
assert.Equal(t, float32(33.45343), form.Lat)
})
t.Run("PortraitLandscapeSquare", func(t *testing.T) {
form := &SearchGeo{Query: "portrait:true landscape:yes square:jo"}
assert.False(t, form.Portrait)
assert.False(t, form.Landscape)
assert.False(t, form.Square)
assert.False(t, form.Panorama)
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.True(t, form.Portrait)
assert.True(t, form.Landscape)
assert.True(t, form.Square)
assert.False(t, form.Panorama)
})
}
func TestGeoSearch_Serialize(t *testing.T) {

View file

@ -7,7 +7,7 @@ import (
// SearchPhotos represents search form fields for "/api/v1/photos".
type SearchPhotos struct {
Query string `form:"q"`
Filter string `form:"filter"`
Filter string `form:"filter" notes:"-"`
UID string `form:"uid"`
Type string `form:"type"`
Path string `form:"path"`
@ -16,7 +16,7 @@ type SearchPhotos struct {
Filename string `form:"filename"`
Original string `form:"original"`
Title string `form:"title"`
Hash string `form:"hash"`
Hash string `form:"hash" example:"hash:2fd4e1c67a2d"`
Primary bool `form:"primary"`
Stack bool `form:"stack"`
Unstacked bool `form:"unstacked"`
@ -27,6 +27,9 @@ type SearchPhotos struct {
Live bool `form:"live"`
Scan bool `form:"scan"`
Panorama bool `form:"panorama"`
Portrait bool `form:"portrait"`
Landscape bool `form:"landscape"`
Square bool `form:"square"`
Error bool `form:"error"`
Hidden bool `form:"hidden"`
Archived bool `form:"archived"`
@ -42,7 +45,6 @@ type SearchPhotos struct {
Chroma uint8 `form:"chroma"`
Diff uint32 `form:"diff"`
Mono bool `form:"mono"`
Portrait bool `form:"portrait"`
Geo bool `form:"geo"`
Keywords string `form:"keywords"` // Filter by keyword(s)
Label string `form:"label"` // Label name
@ -57,16 +59,16 @@ type SearchPhotos struct {
Person string `form:"person"` // Alias for Subject
Subjects string `form:"subjects"` // People names
People string `form:"people"` // Alias for Subjects
Album string `form:"album"` // Album UIDs or name
Albums string `form:"albums"` // Multi search with and/or
Album string `form:"album" notes:"single name with * wildcard"` // Album UIDs or name
Albums string `form:"albums" example:"albums:\"South Africa & Birds\"" notes:"can be combined with & and |"` // Multi search with and/or
Color string `form:"color"` // Main color
Faces string `form:"faces"` // Find or exclude faces if detected.
Quality int `form:"quality"` // Photo quality score
Review bool `form:"review"` // Find photos in review
Camera string `form:"camera"` // Camera UID or name
Lens string `form:"lens"` // Lens UID or name
Before time.Time `form:"before" time_format:"2006-01-02"` // Finds images taken before date
After time.Time `form:"after" time_format:"2006-01-02"` // Finds images taken after date
Camera string `form:"camera" example:"camera:canon"` // Camera UID or name
Lens string `form:"lens" example:"lens:ef24"` // Lens UID or name
Before time.Time `form:"before" time_format:"2006-01-02" notes:"taken before this date"` // Finds images taken before date
After time.Time `form:"after" time_format:"2006-01-02" notes:"taken after this date"` // Finds images taken after date
Count int `form:"count" binding:"required" serialize:"-"` // Result FILE limit
Offset int `form:"offset" serialize:"-"` // Result FILE offset
Order string `form:"order" serialize:"-"` // Sort order

View file

@ -376,6 +376,25 @@ func TestParseQueryString(t *testing.T) {
assert.True(t, form.Portrait)
})
t.Run("PortraitLandscapeSquare", func(t *testing.T) {
form := &SearchPhotos{Query: "portrait:true landscape:yes square:jo"}
assert.False(t, form.Portrait)
assert.False(t, form.Landscape)
assert.False(t, form.Square)
assert.False(t, form.Panorama)
err := form.ParseQueryString()
if err != nil {
t.Fatal(err)
}
assert.True(t, form.Portrait)
assert.True(t, form.Landscape)
assert.True(t, form.Square)
assert.False(t, form.Panorama)
})
t.Run("query for geo with uncommon bool value", func(t *testing.T) {
form := &SearchPhotos{Query: "geo:*cat"}

80
internal/form/table.go Normal file
View file

@ -0,0 +1,80 @@
package form
import (
"fmt"
"reflect"
"time"
"github.com/photoprism/photoprism/pkg/sanitize"
)
// Table returns form fields as table rows for reports.
func Table(f interface{}) (rows [][]string, cols []string) {
cols = []string{"Filter", "Type", "Examples", "Notes"}
v := reflect.ValueOf(f)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return rows, cols
}
rows = make([][]string, 0, v.NumField())
// Iterate through all form fields.
for i := 0; i < v.NumField(); i++ {
fieldValue := v.Field(i)
fieldName := v.Type().Field(i).Tag.Get("form")
fieldInfo := v.Type().Field(i).Tag.Get("serialize")
notes := v.Type().Field(i).Tag.Get("notes")
// Serialize field values as string.
if fieldName != "" && fieldName != "q" && fieldInfo != "-" && notes != "-" {
example := v.Type().Field(i).Tag.Get("example")
typeName := "any"
switch t := fieldValue.Interface().(type) {
case time.Time:
typeName = "timestamp"
if example == "" {
example = fmt.Sprintf("%s:\"2022-01-30 15:23:42\"", fieldName)
}
case int, int8, int16, int32, int64:
typeName = "number"
if example == "" {
example = fmt.Sprintf("%s:0 %s:3", fieldName, fieldName)
}
case uint, uint8, uint16, uint32, uint64:
typeName = "number"
if example == "" {
example = fmt.Sprintf("%s:-1 %s:2", fieldName, fieldName)
}
case float32, float64:
typeName = "decimal"
if example == "" {
example = fmt.Sprintf("%s:1.245", fieldName)
}
case string:
typeName = "string"
if example == "" {
example = fmt.Sprintf("%s:\"name\"", fieldName)
}
case bool:
typeName = "switch"
if example == "" {
example = fmt.Sprintf("%s:yes %s:no", fieldName, fieldName)
}
default:
log.Warnf("failed exporting %T %s", t, sanitize.Token(fieldName))
continue
}
rows = append(rows, []string{fieldName, typeName, example, notes})
}
}
return rows, cols
}

View file

@ -352,9 +352,13 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults,
s = s.Where("photos.photo_panorama = 1")
}
// Find portraits only?
// Find portrait/landscape/square pictures only?
if f.Portrait {
s = s.Where("files.file_portrait = 1")
} else if f.Landscape {
s = s.Where("files.file_aspect_ratio > 1.25")
} else if f.Square {
s = s.Where("files.file_aspect_ratio = 1")
}
if f.Stackable {

View file

@ -62,7 +62,7 @@ func TestPhotosFilterFilename(t *testing.T) {
})
t.Run("1990* or 2790/07/27900704_070228_D6D51B6C.jpg", func(t *testing.T) {
var f form.SearchPhotos
Db().LogMode(true)
// Db().LogMode(true)
f.Filename = "1990* or 2790/07/27900704_070228_D6D51B6C.jpg"
f.Merged = true

View file

@ -296,4 +296,32 @@ func TestPhotosQueryPortrait(t *testing.T) {
assert.Equal(t, len(photos), len(photos0))
})
t.Run("Landscape", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "landscape:true"
f.Merged = true
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 7, len(photos))
})
t.Run("Square", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "square:true"
f.Merged = true
photos, _, err := Photos(f)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 1, len(photos))
})
}

View file

@ -248,6 +248,15 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) {
s = s.Where("photos.photo_panorama = 1")
}
// Find portrait/landscape/square pictures only?
if f.Portrait {
s = s.Where("files.file_portrait = 1")
} else if f.Landscape {
s = s.Where("files.file_aspect_ratio > 1.25")
} else if f.Square {
s = s.Where("files.file_aspect_ratio = 1")
}
// Filter by location country?
if f.Country != "" {
s = s.Where("photos.photo_country IN (?)", SplitOr(strings.ToLower(f.Country)))

View file

@ -813,4 +813,56 @@ func TestGeo(t *testing.T) {
assert.NotEmpty(t, r.ID)
}
})
t.Run("Panorama", func(t *testing.T) {
var f form.SearchGeo
f.Query = "panorama:true"
photos, err := Geo(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("Portrait", func(t *testing.T) {
var f form.SearchGeo
f.Query = "portrait:true"
photos, err := Geo(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("Landscape", func(t *testing.T) {
var f form.SearchGeo
f.Query = "landscape:true"
photos, err := Geo(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
t.Run("Square", func(t *testing.T) {
var f form.SearchGeo
f.Query = "square:true"
photos, err := Geo(f)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 1, len(photos))
})
}