Metadata: Report supported Exiftool, XMP, and Dublin Core tags #2252
Replaces the --no-wrap flag with --md in all "photoprism show ..." subcommands, as this is easier to understand. See also #2247. Unused code was opportunistically removed along the way.
This commit is contained in:
parent
b3113e006f
commit
0096243240
|
@ -63,6 +63,7 @@ var PhotoPrism = []cli.Command{
|
|||
UsersCommand,
|
||||
ShowCommand,
|
||||
VersionCommand,
|
||||
ShowConfigCommand,
|
||||
}
|
||||
|
||||
// childAlreadyRunning tests if a .pid file at filePath is a running process.
|
||||
|
|
|
@ -10,6 +10,7 @@ var ShowCommand = cli.Command{
|
|||
Usage: "Configuration and system report subcommands",
|
||||
Subcommands: []cli.Command{
|
||||
ShowConfigCommand,
|
||||
ShowTagsCommand,
|
||||
ShowFiltersCommand,
|
||||
ShowFormatsCommand,
|
||||
},
|
||||
|
|
|
@ -12,11 +12,11 @@ import (
|
|||
|
||||
var ShowConfigCommand = cli.Command{
|
||||
Name: "config",
|
||||
Usage: "Shows global configuration values",
|
||||
Usage: "Shows global config option names and values",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "no-wrap, n",
|
||||
Usage: "disable text-wrapping",
|
||||
Name: "md, m",
|
||||
Usage: "renders valid Markdown",
|
||||
},
|
||||
},
|
||||
Action: showConfigAction,
|
||||
|
@ -27,9 +27,9 @@ func showConfigAction(ctx *cli.Context) error {
|
|||
conf := config.NewConfig(ctx)
|
||||
conf.SetLogLevel(logrus.FatalLevel)
|
||||
|
||||
rows, cols := conf.Table()
|
||||
rows, cols := conf.Report()
|
||||
|
||||
fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap")))
|
||||
fmt.Println(report.Table(rows, cols, ctx.Bool("md")))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -15,8 +15,8 @@ var ShowFiltersCommand = cli.Command{
|
|||
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",
|
||||
Name: "md, m",
|
||||
Usage: "renders valid Markdown",
|
||||
},
|
||||
},
|
||||
Action: showFiltersAction,
|
||||
|
@ -24,7 +24,7 @@ var ShowFiltersCommand = cli.Command{
|
|||
|
||||
// showFiltersAction lists supported search filters.
|
||||
func showFiltersAction(ctx *cli.Context) error {
|
||||
rows, cols := form.Table(&form.SearchPhotos{})
|
||||
rows, cols := form.Report(&form.SearchPhotos{})
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i][1] == rows[j][1] {
|
||||
|
@ -34,7 +34,7 @@ func showFiltersAction(ctx *cli.Context) error {
|
|||
}
|
||||
})
|
||||
|
||||
fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap")))
|
||||
fmt.Println(report.Table(rows, cols, ctx.Bool("md")))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@ var ShowFormatsCommand = cli.Command{
|
|||
Usage: "hide format descriptions to make the output more compact",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "no-wrap, n",
|
||||
Usage: "disable text-wrapping so the output can be pasted into Markdown files",
|
||||
Name: "md, m",
|
||||
Usage: "renders valid Markdown",
|
||||
},
|
||||
},
|
||||
Action: showFormatsAction,
|
||||
|
@ -27,9 +27,9 @@ var ShowFormatsCommand = cli.Command{
|
|||
|
||||
// showFormatsAction lists supported media and sidecar file formats.
|
||||
func showFormatsAction(ctx *cli.Context) error {
|
||||
rows, cols := fs.Extensions.Formats(true).Table(!ctx.Bool("compact"), true, true)
|
||||
rows, cols := fs.Extensions.Formats(true).Report(!ctx.Bool("compact"), true, true)
|
||||
|
||||
fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap")))
|
||||
fmt.Println(report.Table(rows, cols, ctx.Bool("md")))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
41
internal/commands/show_tags.go
Normal file
41
internal/commands/show_tags.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/meta"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/report"
|
||||
)
|
||||
|
||||
var ShowTagsCommand = cli.Command{
|
||||
Name: "tags",
|
||||
Usage: "Reports supported Exif and XMP metadata tags",
|
||||
Flags: []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "md, m",
|
||||
Usage: "renders valid Markdown",
|
||||
},
|
||||
},
|
||||
Action: showTagsAction,
|
||||
}
|
||||
|
||||
// showTagsAction reports supported Exif and XMP metadata tags.
|
||||
func showTagsAction(ctx *cli.Context) error {
|
||||
rows, cols := meta.Report(&meta.Data{})
|
||||
|
||||
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.Table(rows, cols, ctx.Bool("md")))
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,17 +1,14 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
// Table returns global config values as a table for reporting.
|
||||
func (c *Config) Table() (rows [][]string, cols []string) {
|
||||
// Report returns global config values as a table for reporting.
|
||||
func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
cols = []string{"Value", "Name"}
|
||||
|
||||
rows = [][]string{
|
||||
|
@ -166,22 +163,3 @@ func (c *Config) Table() (rows [][]string, cols []string) {
|
|||
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// MarkdownTable returns global config values as a markdown formatted table.
|
||||
func (c *Config) MarkdownTable(autoWrap bool) string {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
rows, cols := c.Table()
|
||||
|
||||
table := tablewriter.NewWriter(buf)
|
||||
|
||||
table.SetAutoWrapText(autoWrap)
|
||||
table.SetAutoFormatHeaders(false)
|
||||
table.SetHeader(cols)
|
||||
table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(rows)
|
||||
table.Render()
|
||||
|
||||
return buf.String()
|
||||
}
|
|
@ -544,13 +544,18 @@ func (m *File) Links() Links {
|
|||
return FindLinks("", m.FileUID)
|
||||
}
|
||||
|
||||
// Panorama tests if the file seems to be a panorama image.
|
||||
// Panorama checks if the file appears to be a panoramic image.
|
||||
func (m *File) Panorama() bool {
|
||||
if m.FileSidecar || m.FileWidth <= 1000 || m.FileHeight <= 500 {
|
||||
// Too small.
|
||||
return false
|
||||
} else if m.Projection() != ProjDefault {
|
||||
// Panoramic projection.
|
||||
return true
|
||||
}
|
||||
|
||||
return m.Projection() != ProjDefault || (m.FileWidth/m.FileHeight) >= 2
|
||||
// Decide based on aspect ratio.
|
||||
return float64(m.FileWidth)/float64(m.FileHeight) > 1.9
|
||||
}
|
||||
|
||||
// Projection returns the panorama projection name if any.
|
||||
|
|
|
@ -278,8 +278,8 @@ func TestFile_Panorama(t *testing.T) {
|
|||
assert.True(t, file.Panorama())
|
||||
})
|
||||
t.Run("1999", func(t *testing.T) {
|
||||
file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1999, FileHeight: 1000}
|
||||
assert.False(t, file.Panorama())
|
||||
file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1910, FileHeight: 1000}
|
||||
assert.True(t, file.Panorama())
|
||||
})
|
||||
t.Run("2000", func(t *testing.T) {
|
||||
file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 2000, FileHeight: 1000}
|
||||
|
|
|
@ -8,8 +8,8 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/sanitize"
|
||||
)
|
||||
|
||||
// Table returns form fields as table rows for reports.
|
||||
func Table(f interface{}) (rows [][]string, cols []string) {
|
||||
// Report returns form fields as table rows for reports.
|
||||
func Report(f interface{}) (rows [][]string, cols []string) {
|
||||
cols = []string{"Filter", "Type", "Examples", "Notes"}
|
||||
|
||||
v := reflect.ValueOf(f)
|
||||
|
@ -26,6 +26,9 @@ func Table(f interface{}) (rows [][]string, cols []string) {
|
|||
|
||||
// Iterate through all form fields.
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if !v.Type().Field(i).IsExported() {
|
||||
continue
|
||||
}
|
||||
fieldValue := v.Field(i)
|
||||
fieldName := v.Type().Field(i).Tag.Get("form")
|
||||
fieldInfo := v.Type().Field(i).Tag.Get("serialize")
|
||||
|
@ -68,7 +71,7 @@ func Table(f interface{}) (rows [][]string, cols []string) {
|
|||
example = fmt.Sprintf("%s:yes %s:no", fieldName, fieldName)
|
||||
}
|
||||
default:
|
||||
log.Warnf("failed exporting %T %s", t, sanitize.Token(fieldName))
|
||||
log.Warnf("failed reporting on %T %s", t, sanitize.Token(fieldName))
|
||||
continue
|
||||
}
|
||||
|
|
@ -22,13 +22,13 @@ type SearchPhotos struct {
|
|||
Unstacked bool `form:"unstacked"`
|
||||
Stackable bool `form:"stackable"`
|
||||
Video bool `form:"video"`
|
||||
Vector bool `form:"vector" notes:"Vector graphics such as SVGs"`
|
||||
Animated bool `form:"animated" notes:"Animated images such as GIFs"`
|
||||
Photo bool `form:"photo" notes:"Everything except videos"`
|
||||
Raw bool `form:"raw" notes:"RAW images only"`
|
||||
Live bool `form:"live" notes:"Live photos, short videos"`
|
||||
Scan bool `form:"scan" notes:"Scanned images and documents"`
|
||||
Panorama bool `form:"panorama" notes:"Aspect ratio 2:1 and up"`
|
||||
Vector bool `form:"vector" notes:"Vector Graphics"`
|
||||
Animated bool `form:"animated" notes:"Animated GIFs"`
|
||||
Photo bool `form:"photo" notes:"No Videos"`
|
||||
Raw bool `form:"raw" notes:"RAW Images"`
|
||||
Live bool `form:"live" notes:"Live Photos, Short Videos"`
|
||||
Scan bool `form:"scan" notes:"Scanned Images, Documents"`
|
||||
Panorama bool `form:"panorama" notes:"Aspect Ratio > 1.9:1"`
|
||||
Portrait bool `form:"portrait"`
|
||||
Landscape bool `form:"landscape"`
|
||||
Square bool `form:"square"`
|
||||
|
@ -48,33 +48,33 @@ type SearchPhotos struct {
|
|||
Diff uint32 `form:"diff"`
|
||||
Mono bool `form:"mono"`
|
||||
Geo bool `form:"geo"`
|
||||
Keywords string `form:"keywords"` // Filter by keyword(s)
|
||||
Label string `form:"label"` // Label name
|
||||
Category string `form:"category"` // Moments
|
||||
Country string `form:"country"` // Moments
|
||||
State string `form:"state"` // Moments
|
||||
Year string `form:"year"` // Moments
|
||||
Month string `form:"month"` // Moments
|
||||
Day string `form:"day"` // Moments
|
||||
Face string `form:"face"` // UIDs
|
||||
Subject string `form:"subject"` // UIDs
|
||||
Person string `form:"person"` // Alias for Subject
|
||||
Subjects string `form:"subjects"` // People names
|
||||
People string `form:"people"` // Alias for Subjects
|
||||
Album string `form:"album" notes:"Single name with * wildcard"` // Album UIDs or name
|
||||
Albums string `form:"albums" example:"albums:\"South Africa & Birds\"" notes:"May 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" 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
|
||||
Merged bool `form:"merged" serialize:"-"` // Merge FILES in response
|
||||
Keywords string `form:"keywords"` // Filter by keyword(s)
|
||||
Label string `form:"label"` // Label name
|
||||
Category string `form:"category"` // Moments
|
||||
Country string `form:"country"` // Moments
|
||||
State string `form:"state"` // Moments
|
||||
Year string `form:"year"` // Moments
|
||||
Month string `form:"month"` // Moments
|
||||
Day string `form:"day"` // Moments
|
||||
Face string `form:"face"` // UIDs
|
||||
Subject string `form:"subject"` // UIDs
|
||||
Person string `form:"person"` // Alias for Subject
|
||||
Subjects string `form:"subjects"` // People names
|
||||
People string `form:"people"` // Alias for Subjects
|
||||
Album string `form:"album" notes:"Single name with * wildcard"` // Album UIDs or name
|
||||
Albums string `form:"albums" example:"albums:\"South Africa & Birds\"" notes:"Album names 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" 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
|
||||
Merged bool `form:"merged" serialize:"-"` // Merge FILES in response
|
||||
}
|
||||
|
||||
func (f *SearchPhotos) GetQuery() string {
|
||||
|
|
|
@ -17,7 +17,7 @@ type Data struct {
|
|||
FileName string `meta:"FileName"`
|
||||
DocumentID string `meta:"BurstUUID,MediaGroupUUID,ImageUniqueID,OriginalDocumentID,DocumentID"`
|
||||
InstanceID string `meta:"InstanceID,DocumentID"`
|
||||
TakenAt time.Time `meta:"DateTimeOriginal,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,DateTimeDigitized,DateTime,SubSecDateTimeOriginal,SubSecCreateDate"`
|
||||
TakenAt time.Time `meta:"DateTimeOriginal,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,DateTimeDigitized,DateTime,SubSecDateTimeOriginal,SubSecCreateDate" xmp:"DateCreated"`
|
||||
TakenAtLocal time.Time `meta:"DateTimeOriginal,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,DateTimeDigitized,DateTime,SubSecDateTimeOriginal,SubSecCreateDate"`
|
||||
TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"`
|
||||
TakenNs int `meta:"-"`
|
||||
|
@ -26,27 +26,27 @@ type Data struct {
|
|||
FPS float64 `meta:"VideoFrameRate,VideoAvgFrameRate"`
|
||||
Frames int `meta:"FrameCount"`
|
||||
Codec string `meta:"CompressorID,FileType"`
|
||||
Title string `meta:"Title"`
|
||||
Subject string `meta:"Subject,PersonInImage,ObjectName,HierarchicalSubject,CatalogSets"`
|
||||
Title string `meta:"Title" xmp:"dc:title" dc:"title,title.Alt"`
|
||||
Subject string `meta:"Subject,PersonInImage,ObjectName,HierarchicalSubject,CatalogSets" xmp:"Subject"`
|
||||
Keywords Keywords `meta:"Keywords"`
|
||||
Notes string `meta:"Comment"`
|
||||
Artist string `meta:"Artist,Creator,OwnerName,Owner"`
|
||||
Description string `meta:"Description"`
|
||||
Copyright string `meta:"Rights,Copyright,WebStatement,Certificate"`
|
||||
Artist string `meta:"Artist,Creator,OwnerName,Owner" xmp:"Creator"`
|
||||
Description string `meta:"Description" xmp:"Description,Description.Alt"`
|
||||
Copyright string `meta:"Rights,Copyright,WebStatement" xmp:"Rights,Rights.Alt"`
|
||||
License string `meta:"UsageTerms,License"`
|
||||
Projection string `meta:"ProjectionType"`
|
||||
ColorProfile string `meta:"ICCProfileName,ProfileDescription"`
|
||||
CameraMake string `meta:"CameraMake,Make"`
|
||||
CameraModel string `meta:"CameraModel,Model"`
|
||||
CameraMake string `meta:"CameraMake,Make" xmp:"Make"`
|
||||
CameraModel string `meta:"CameraModel,Model" xmp:"Model"`
|
||||
CameraOwner string `meta:"OwnerName"`
|
||||
CameraSerial string `meta:"SerialNumber"`
|
||||
LensMake string `meta:"LensMake"`
|
||||
LensModel string `meta:"Lens,LensModel"`
|
||||
LensModel string `meta:"Lens,LensModel" xmp:"LensModel"`
|
||||
Software string `meta:"Software,HistorySoftwareAgent,ProcessingSoftware"`
|
||||
Flash bool `meta:"-"`
|
||||
Flash bool `meta:"FlashFired"`
|
||||
FocalLength int `meta:"FocalLength"`
|
||||
Exposure string `meta:"ExposureTime"`
|
||||
Aperture float32 `meta:"ApertureValue"`
|
||||
Exposure string `meta:"ExposureTime,ShutterSpeedValue,ShutterSpeed,TargetExposureTime"`
|
||||
Aperture float32 `meta:"ApertureValue,Aperture"`
|
||||
FNumber float32 `meta:"FNumber"`
|
||||
Iso int `meta:"ISO"`
|
||||
ImageType int `meta:"HDRImageType"`
|
||||
|
@ -73,16 +73,14 @@ func NewData() Data {
|
|||
|
||||
// AspectRatio returns the aspect ratio based on width and height.
|
||||
func (data Data) AspectRatio() float32 {
|
||||
width := float64(data.ActualWidth())
|
||||
height := float64(data.ActualHeight())
|
||||
w := float64(data.ActualWidth())
|
||||
h := float64(data.ActualHeight())
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
aspectRatio := float32(math.Round((width/height)*100) / 100)
|
||||
|
||||
return aspectRatio
|
||||
return float32(math.Round((w/h)*100) / 100)
|
||||
}
|
||||
|
||||
// Portrait returns true if it is a portrait picture or video based on width and height.
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/sanitize"
|
||||
|
||||
"github.com/dsoprea/go-exif/v3"
|
||||
)
|
||||
|
||||
|
@ -13,18 +15,21 @@ var GpsFloatRegexp = regexp.MustCompile("[+\\-]?(?:(?:0|[1-9]\\d*)(?:\\.\\d*)?|\
|
|||
|
||||
// GpsToLatLng returns the GPS latitude and longitude as float point number.
|
||||
func GpsToLatLng(s string) (lat, lng float32) {
|
||||
// Emtpy?
|
||||
if s == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Floating point numbers?
|
||||
if fl := GpsFloatRegexp.FindAllString(s, -1); len(fl) == 2 {
|
||||
lat, _ := strconv.ParseFloat(fl[0], 64)
|
||||
lng, _ := strconv.ParseFloat(fl[1], 64)
|
||||
return float32(lat), float32(lng)
|
||||
if lat, err := strconv.ParseFloat(fl[0], 64); err != nil {
|
||||
log.Infof("metadata: %s is not a valid gps position", sanitize.Log(fl[0]))
|
||||
} else if lng, err := strconv.ParseFloat(fl[1], 64); err == nil {
|
||||
return float32(lat), float32(lng)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse human readable strings.
|
||||
// Parse string values.
|
||||
co := GpsCoordsRegexp.FindAllString(s, -1)
|
||||
re := GpsRefRegexp.FindAllString(s, -1)
|
||||
|
||||
|
@ -34,16 +39,16 @@ func GpsToLatLng(s string) (lat, lng float32) {
|
|||
|
||||
latDeg := exif.GpsDegrees{
|
||||
Orientation: re[0][0],
|
||||
Degrees: GpsCoord(co[0]),
|
||||
Minutes: GpsCoord(co[1]),
|
||||
Seconds: GpsCoord(co[2]),
|
||||
Degrees: ParseFloat(co[0]),
|
||||
Minutes: ParseFloat(co[1]),
|
||||
Seconds: ParseFloat(co[2]),
|
||||
}
|
||||
|
||||
lngDeg := exif.GpsDegrees{
|
||||
Orientation: re[1][0],
|
||||
Degrees: GpsCoord(co[3]),
|
||||
Minutes: GpsCoord(co[4]),
|
||||
Seconds: GpsCoord(co[5]),
|
||||
Degrees: ParseFloat(co[3]),
|
||||
Minutes: ParseFloat(co[4]),
|
||||
Seconds: ParseFloat(co[5]),
|
||||
}
|
||||
|
||||
return float32(latDeg.Decimal()), float32(lngDeg.Decimal())
|
||||
|
@ -51,10 +56,17 @@ func GpsToLatLng(s string) (lat, lng float32) {
|
|||
|
||||
// GpsToDecimal returns the GPS latitude or longitude as decimal float point number.
|
||||
func GpsToDecimal(s string) float32 {
|
||||
// Emtpy?
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Floating point number?
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return float32(f)
|
||||
}
|
||||
|
||||
// Parse string value.
|
||||
co := GpsCoordsRegexp.FindAllString(s, -1)
|
||||
re := GpsRefRegexp.FindAllString(s, -1)
|
||||
|
||||
|
@ -64,26 +76,26 @@ func GpsToDecimal(s string) float32 {
|
|||
|
||||
latDeg := exif.GpsDegrees{
|
||||
Orientation: re[0][0],
|
||||
Degrees: GpsCoord(co[0]),
|
||||
Minutes: GpsCoord(co[1]),
|
||||
Seconds: GpsCoord(co[2]),
|
||||
Degrees: ParseFloat(co[0]),
|
||||
Minutes: ParseFloat(co[1]),
|
||||
Seconds: ParseFloat(co[2]),
|
||||
}
|
||||
|
||||
return float32(latDeg.Decimal())
|
||||
}
|
||||
|
||||
// GpsCoord returns a single GPS coordinate value as floating point number (degree, minute or second).
|
||||
func GpsCoord(s string) float64 {
|
||||
// ParseFloat returns a single GPS coordinate value as floating point number (degree, minute or second).
|
||||
func ParseFloat(s string) float64 {
|
||||
// Empty?
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
result, err := strconv.ParseFloat(s, 64)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("metadata: failed parsing GPS coordinate '%s'", s)
|
||||
// Parse floating point number.
|
||||
if result, err := strconv.ParseFloat(s, 64); err != nil {
|
||||
log.Debugf("metadata: %s is not a valid gps position", sanitize.Log(s))
|
||||
return 0
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -70,17 +70,17 @@ func TestGpsToDecimal(t *testing.T) {
|
|||
|
||||
func TestGpsCoord(t *testing.T) {
|
||||
t.Run("valid string", func(t *testing.T) {
|
||||
r := GpsCoord("51")
|
||||
r := ParseFloat("51")
|
||||
assert.Equal(t, float64(51), r)
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
r := GpsCoord("")
|
||||
r := ParseFloat("")
|
||||
assert.Equal(t, float64(0), r)
|
||||
})
|
||||
|
||||
t.Run("invalid string", func(t *testing.T) {
|
||||
r := GpsCoord("abc")
|
||||
r := ParseFloat("abc")
|
||||
assert.Equal(t, float64(0), r)
|
||||
})
|
||||
}
|
||||
|
|
75
internal/meta/report.go
Normal file
75
internal/meta/report.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
package meta
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/sanitize"
|
||||
)
|
||||
|
||||
// Report returns form fields as table rows for reports.
|
||||
func Report(f interface{}) (rows [][]string, cols []string) {
|
||||
cols = []string{"Tag", "Type", "Exiftool", "XMP", "Dublin Core"}
|
||||
|
||||
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++ {
|
||||
if !v.Type().Field(i).IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := v.Field(i)
|
||||
|
||||
fieldName := v.Type().Field(i).Name
|
||||
metaTags := v.Type().Field(i).Tag.Get("meta")
|
||||
xmpTags := v.Type().Field(i).Tag.Get("xmp")
|
||||
dcTags := v.Type().Field(i).Tag.Get("dc")
|
||||
|
||||
// Serialize field values as string.
|
||||
if metaTags != "" && metaTags != "-" {
|
||||
typeName := "any"
|
||||
|
||||
switch t := fieldValue.Interface().(type) {
|
||||
case Keywords:
|
||||
typeName = "keywords"
|
||||
case time.Duration:
|
||||
typeName = "duration"
|
||||
case time.Time:
|
||||
typeName = "timestamp"
|
||||
case int, int8, int16, int32, int64:
|
||||
typeName = "number"
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
typeName = "number"
|
||||
case float32, float64:
|
||||
typeName = "decimal"
|
||||
case string:
|
||||
typeName = "text"
|
||||
case bool:
|
||||
typeName = "flag"
|
||||
default:
|
||||
log.Warnf("failed reporting on %T %s", t, sanitize.Token(fieldName))
|
||||
continue
|
||||
}
|
||||
|
||||
metaTags = strings.ReplaceAll(metaTags, ",", ", ")
|
||||
xmpTags = strings.ReplaceAll(xmpTags, ",", ", ")
|
||||
dcTags = strings.ReplaceAll(dcTags, ",", ", ")
|
||||
|
||||
rows = append(rows, []string{fieldName, typeName, metaTags, xmpTags, dcTags})
|
||||
}
|
||||
}
|
||||
|
||||
return rows, cols
|
||||
}
|
|
@ -9,37 +9,12 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/sanitize"
|
||||
)
|
||||
|
||||
// IDs represents a list of identifier strings.
|
||||
type IDs []string
|
||||
|
||||
// FaceMap maps identification strings to face entities.
|
||||
type FaceMap map[string]entity.Face
|
||||
|
||||
// IDs returns all known face ids as slice.
|
||||
func (m FaceMap) IDs() (ids IDs) {
|
||||
ids = make(IDs, len(m))
|
||||
|
||||
for id := range m {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// Refresh updates the map with current entity values from the database.
|
||||
func (m FaceMap) Refresh() (err error) {
|
||||
result := entity.Faces{}
|
||||
|
||||
ids := m.IDs()
|
||||
|
||||
if err = Db().Where("id IN (?)", ids).Find(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range result {
|
||||
m[f.ID] = f
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FacesByID retrieves faces from the database and returns a map with the Face ID as key.
|
||||
func FacesByID(knownOnly, unmatchedOnly, inclHidden bool) (FaceMap, IDs, error) {
|
||||
faces, err := Faces(knownOnly, unmatchedOnly, inclHidden)
|
||||
|
|
|
@ -97,8 +97,8 @@ var FormatDesc = map[Format]string{
|
|||
FormatOther: "Other",
|
||||
}
|
||||
|
||||
// Table returns a file format documentation table.
|
||||
func (m FileFormats) Table(withDesc, withType, withExt bool) (rows [][]string, cols []string) {
|
||||
// Report returns a file format documentation table.
|
||||
func (m FileFormats) Report(withDesc, withType, withExt bool) (rows [][]string, cols []string) {
|
||||
cols = make([]string, 0, 4)
|
||||
cols = append(cols, "Format")
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
func TestFileFormats_Markdown(t *testing.T) {
|
||||
t.Run("All", func(t *testing.T) {
|
||||
f := Extensions.Formats(true)
|
||||
rows, cols := f.Table(true, true, true)
|
||||
rows, cols := f.Report(true, true, true)
|
||||
assert.NotEmpty(t, rows)
|
||||
assert.NotEmpty(t, cols)
|
||||
assert.Len(t, cols, 4)
|
||||
|
@ -17,7 +17,7 @@ func TestFileFormats_Markdown(t *testing.T) {
|
|||
})
|
||||
t.Run("Compact", func(t *testing.T) {
|
||||
f := Extensions.Formats(true)
|
||||
rows, cols := f.Table(false, false, false)
|
||||
rows, cols := f.Report(false, false, false)
|
||||
assert.NotEmpty(t, rows)
|
||||
assert.NotEmpty(t, cols)
|
||||
assert.Len(t, cols, 1)
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
// Markdown returns markdown formatted table.
|
||||
func Markdown(rows [][]string, cols []string, autoWrap bool) string {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
table := tablewriter.NewWriter(buf)
|
||||
|
||||
table.SetAutoWrapText(autoWrap)
|
||||
table.SetAutoFormatHeaders(false)
|
||||
table.SetHeader(cols)
|
||||
table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true})
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(rows)
|
||||
table.Render()
|
||||
|
||||
return buf.String()
|
||||
}
|
33
pkg/report/table.go
Normal file
33
pkg/report/table.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
// Table returns a text-formatted table, optionally as valid Markdown,
|
||||
// so the output can be pasted into the docs.
|
||||
func Table(rows [][]string, cols []string, markDown bool) string {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// Configure.
|
||||
borders := tablewriter.Border{
|
||||
Left: true,
|
||||
Right: true,
|
||||
Top: !markDown,
|
||||
Bottom: !markDown,
|
||||
}
|
||||
|
||||
// Render.
|
||||
table := tablewriter.NewWriter(buf)
|
||||
table.SetAutoWrapText(!markDown)
|
||||
table.SetAutoFormatHeaders(false)
|
||||
table.SetHeader(cols)
|
||||
table.SetBorders(borders)
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(rows)
|
||||
table.Render()
|
||||
|
||||
return buf.String()
|
||||
}
|
Loading…
Reference in a new issue