Metadata: Add support for XMP sidecar CreateDate and Keywords (#1161)

* Metadata: Read title, description, date and keywords from apple xmp

* Metadata: Add testfiles and tests

* Metadata: Add support for XMP sidecar CreateDate and Keywords #1151

Co-authored-by: Michael Mayer <michael@lastzero.net>
This commit is contained in:
Theresa Gresch 2021-04-26 13:54:14 +02:00 committed by GitHub
parent 180e46b95f
commit da6e948f31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 247 additions and 47 deletions

25
internal/meta/testdata/apple-test-2.xmp vendored Normal file
View file

@ -0,0 +1,25 @@
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<exif:GPSLongitude>13.369367199999999</exif:GPSLongitude>
<exif:GPSLongitudeRef>E</exif:GPSLongitudeRef>
<exif:GPSHPositioningError>1</exif:GPSHPositioningError>
<exif:GPSLatitude>52.5250816</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
<exif:GPSTimeStamp>2021-03-26T09:18:59Z</exif:GPSTimeStamp>
<dc:title>Botanischer Garten</dc:title>
<dc:description>Tulpen am See</dc:description>
<dc:subject>
<rdf:Seq>
<rdf:li>Krokus</rdf:li>
<rdf:li>Blume</rdf:li>
<rdf:li>Schöne Wiese</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2021-03-24T13:07:29+01:00</photoshop:DateCreated>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View file

@ -57,5 +57,13 @@ func (data *Data) XMP(fileName string) (err error) {
data.LensModel = doc.LensModel() data.LensModel = doc.LensModel()
} }
if takenAt := doc.TakenAt(); !takenAt.IsZero() {
data.TakenAt = takenAt
}
if len(doc.Keywords()) != 0 {
data.AddKeywords(doc.Keywords())
}
return nil return nil
} }

View file

@ -3,6 +3,8 @@ package meta
import ( import (
"encoding/xml" "encoding/xml"
"io/ioutil" "io/ioutil"
"strings"
"time"
) )
// XmpDocument represents an XMP sidecar file. // XmpDocument represents an XMP sidecar file.
@ -75,6 +77,10 @@ type XmpDocument struct {
Text string `xml:",chardata" json:"text,omitempty"` Text string `xml:",chardata" json:"text,omitempty"`
Li []string `xml:"li"` // desk, coffee, computer Li []string `xml:"li"` // desk, coffee, computer
} `xml:"Bag" json:"bag,omitempty"` } `xml:"Bag" json:"bag,omitempty"`
Seq struct {
Text string `xml:",chardata" json:"text,omitempty"`
Li []string `xml:"li"` // desk, coffee, computer
} `xml:"Seq" json:"seq,omitempty"`
} `xml:"subject" json:"subject,omitempty"` } `xml:"subject" json:"subject,omitempty"`
Rights struct { Rights struct {
Text string `xml:",chardata" json:"text,omitempty"` Text string `xml:",chardata" json:"text,omitempty"`
@ -192,6 +198,7 @@ type XmpDocument struct {
} `xml:"RDF" json:"rdf,omitempty"` } `xml:"RDF" json:"rdf,omitempty"`
} }
// Load parses an XMP file and populates document values with its contents.
func (doc *XmpDocument) Load(filename string) error { func (doc *XmpDocument) Load(filename string) error {
data, err := ioutil.ReadFile(filename) data, err := ioutil.ReadFile(filename)
@ -202,30 +209,81 @@ func (doc *XmpDocument) Load(filename string) error {
return xml.Unmarshal(data, doc) return xml.Unmarshal(data, doc)
} }
// Title returns the XMP document title.
func (doc *XmpDocument) Title() string { func (doc *XmpDocument) Title() string {
return SanitizeTitle(doc.RDF.Description.Title.Alt.Li.Text) t := doc.RDF.Description.Title.Alt.Li.Text
t2 := doc.RDF.Description.Title.Text
if t != "" {
return SanitizeTitle(t)
} else if t2 != "" {
return SanitizeTitle(t2)
}
return ""
} }
// Artist returns the XMP document artist.
func (doc *XmpDocument) Artist() string { func (doc *XmpDocument) Artist() string {
return SanitizeString(doc.RDF.Description.Creator.Seq.Li) return SanitizeString(doc.RDF.Description.Creator.Seq.Li)
} }
// Description returns the XMP document description.
func (doc *XmpDocument) Description() string { func (doc *XmpDocument) Description() string {
return SanitizeDescription(doc.RDF.Description.Description.Alt.Li.Text) d := doc.RDF.Description.Description.Alt.Li.Text
d2 := doc.RDF.Description.Description.Text
if d != "" {
return SanitizeDescription(d)
} else if d2 != "" {
return SanitizeTitle(d2)
}
return ""
} }
// Copyright returns the XMP document copyright info.
func (doc *XmpDocument) Copyright() string { func (doc *XmpDocument) Copyright() string {
return SanitizeString(doc.RDF.Description.Rights.Alt.Li.Text) return SanitizeString(doc.RDF.Description.Rights.Alt.Li.Text)
} }
// CameraMake returns the XMP document camera make name.
func (doc *XmpDocument) CameraMake() string { func (doc *XmpDocument) CameraMake() string {
return SanitizeString(doc.RDF.Description.Make) return SanitizeString(doc.RDF.Description.Make)
} }
// CameraModel returns the XMP document camera model name.
func (doc *XmpDocument) CameraModel() string { func (doc *XmpDocument) CameraModel() string {
return SanitizeString(doc.RDF.Description.Model) return SanitizeString(doc.RDF.Description.Model)
} }
// LensModel returns the XMP document lens model name.
func (doc *XmpDocument) LensModel() string { func (doc *XmpDocument) LensModel() string {
return SanitizeString(doc.RDF.Description.LensModel) return SanitizeString(doc.RDF.Description.LensModel)
} }
// TakenAt returns the XMP document taken date.
func (doc *XmpDocument) TakenAt() time.Time {
taken := time.Time{} // Unknown
s := SanitizeString(doc.RDF.Description.DateCreated)
if s == "" {
return taken
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
taken = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999999999", s); err == nil {
taken = t
} else if t, err := time.Parse("2006-01-02T15:04:05-07:00", s); err == nil {
taken = t
} else if t, err := time.Parse("2006-01-02T15:04:05", s[:19]); err == nil {
taken = t
}
return taken
}
// Keywords returns the XMP document keywords.
func (doc *XmpDocument) Keywords() string {
s := doc.RDF.Description.Subject.Seq.Li
return strings.Join(s, ", ")
}

View file

@ -2,11 +2,25 @@ package meta
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestXMP(t *testing.T) { func TestXMP(t *testing.T) {
t.Run("apple xmp 2", func(t *testing.T) {
data, err := XMP("testdata/apple-test-2.xmp")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Botanischer Garten", data.Title)
assert.Equal(t, time.Date(2021, 3, 24, 13, 07, 29, 0, time.FixedZone("", +3600)), data.TakenAt)
assert.Equal(t, "Tulpen am See", data.Description)
assert.Equal(t, Keywords{"blume", "krokus", "schöne", "wiese"}, data.Keywords)
})
t.Run("photoshop", func(t *testing.T) { t.Run("photoshop", func(t *testing.T) {
data, err := XMP("testdata/photoshop.xmp") data, err := XMP("testdata/photoshop.xmp")
@ -15,6 +29,8 @@ func TestXMP(t *testing.T) {
} }
assert.Equal(t, "Night Shift / Berlin / 2020", data.Title) assert.Equal(t, "Night Shift / Berlin / 2020", data.Title)
t.Log(data.TakenAt)
assert.Equal(t, time.Date(2020, 1, 1, 17, 28, 25, 729626112, time.UTC), data.TakenAt)
assert.Equal(t, "Michael Mayer", data.Artist) assert.Equal(t, "Michael Mayer", data.Artist)
assert.Equal(t, "Example file for development", data.Description) assert.Equal(t, "Example file for development", data.Description)
assert.Equal(t, "This is an (edited) legal notice", data.Copyright) assert.Equal(t, "This is an (edited) legal notice", data.Copyright)

View file

@ -13,61 +13,129 @@ import (
) )
func TestIndexRelated(t *testing.T) { func TestIndexRelated(t *testing.T) {
conf := config.TestConfig() t.Run("2018-04-12 19_24_49.gif", func(t *testing.T) {
conf := config.TestConfig()
testFile, err := NewMediaFile("testdata/2018-04-12 19_24_49.gif") testFile, err := NewMediaFile("testdata/2018-04-12 19_24_49.gif")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
}
testRelated, err := testFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
testToken := rnd.Token(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
for _, f := range testRelated.Files {
dest := filepath.Join(testPath, f.BaseName())
if err := f.Copy(dest); err != nil {
t.Fatalf("COPY FAILED: %s", err)
} }
}
mainFile, err := NewMediaFile(filepath.Join(testPath, "2018-04-12 19_24_49.gif")) testRelated, err := testFile.RelatedFiles(true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
related, err := mainFile.RelatedFiles(true) testToken := rnd.Token(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
if err != nil { for _, f := range testRelated.Files {
t.Fatal(err) dest := filepath.Join(testPath, f.BaseName())
}
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow()) if err := f.Copy(dest); err != nil {
nd := nsfw.New(conf.NSFWModelPath()) t.Fatalf("copying test file failed: %s", err)
convert := NewConvert(conf) }
}
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos()) mainFile, err := NewMediaFile(filepath.Join(testPath, "2018-04-12 19_24_49.gif"))
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt) if err != nil {
t.Fatal(err)
}
assert.False(t, result.Failed()) related, err := mainFile.RelatedFiles(true)
assert.False(t, result.Stacked())
assert.True(t, result.Success())
assert.Equal(t, IndexAdded, result.Status)
if photo, err := query.PhotoByUID(result.PhotoUID); err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else { }
assert.Equal(t, "2018-04-12 19:24:49 +0000 UTC", photo.TakenAt.String())
assert.Equal(t, "name", photo.TakenSrc) tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
} nd := nsfw.New(conf.NSFWModelPath())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)
assert.False(t, result.Failed())
assert.False(t, result.Stacked())
assert.True(t, result.Success())
assert.Equal(t, IndexAdded, result.Status)
if photo, err := query.PhotoByUID(result.PhotoUID); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "2018-04-12 19:24:49 +0000 UTC", photo.TakenAt.String())
assert.Equal(t, "name", photo.TakenSrc)
}
})
t.Run("apple-test-2.jpg", func(t *testing.T) {
conf := config.TestConfig()
testFile, err := NewMediaFile("testdata/apple-test-2.jpg")
if err != nil {
t.Fatal(err)
}
testRelated, err := testFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
testToken := rnd.Token(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
for _, f := range testRelated.Files {
dest := filepath.Join(testPath, f.BaseName())
if err := f.Copy(dest); err != nil {
t.Fatalf("copying test file failed: %s", err)
}
}
mainFile, err := NewMediaFile(filepath.Join(testPath, "apple-test-2.jpg"))
if err != nil {
t.Fatal(err)
}
related, err := mainFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)
assert.False(t, result.Failed())
assert.False(t, result.Stacked())
assert.True(t, result.Success())
assert.Equal(t, IndexAdded, result.Status)
if photo, err := query.PhotoByUID(result.PhotoUID); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "Botanischer Garten", photo.PhotoTitle)
assert.Equal(t, "Tulpen am See", photo.PhotoDescription)
assert.Contains(t, photo.Details.Keywords, "krokus")
assert.Contains(t, photo.Details.Keywords, "blume")
assert.Contains(t, photo.Details.Keywords, "schöne")
assert.Contains(t, photo.Details.Keywords, "wiese")
assert.Equal(t, "2021-03-24 12:07:29 +0000 UTC", photo.TakenAt.String())
assert.Equal(t, "xmp", photo.TakenSrc)
}
})
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

View file

@ -0,0 +1,25 @@
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<exif:GPSLongitude>13.369367199999999</exif:GPSLongitude>
<exif:GPSLongitudeRef>E</exif:GPSLongitudeRef>
<exif:GPSHPositioningError>1</exif:GPSHPositioningError>
<exif:GPSLatitude>52.5250816</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
<exif:GPSTimeStamp>2021-03-26T09:18:59Z</exif:GPSTimeStamp>
<dc:title>Botanischer Garten</dc:title>
<dc:description>Tulpen am See</dc:description>
<dc:subject>
<rdf:Seq>
<rdf:li>Krokus</rdf:li>
<rdf:li>Blume</rdf:li>
<rdf:li>Schöne Wiese</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2021-03-24T13:07:29+01:00</photoshop:DateCreated>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>