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()
}
if takenAt := doc.TakenAt(); !takenAt.IsZero() {
data.TakenAt = takenAt
}
if len(doc.Keywords()) != 0 {
data.AddKeywords(doc.Keywords())
}
return nil
}

View file

@ -3,6 +3,8 @@ package meta
import (
"encoding/xml"
"io/ioutil"
"strings"
"time"
)
// XmpDocument represents an XMP sidecar file.
@ -75,6 +77,10 @@ type XmpDocument struct {
Text string `xml:",chardata" json:"text,omitempty"`
Li []string `xml:"li"` // desk, coffee, computer
} `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"`
Rights struct {
Text string `xml:",chardata" json:"text,omitempty"`
@ -192,6 +198,7 @@ type XmpDocument struct {
} `xml:"RDF" json:"rdf,omitempty"`
}
// Load parses an XMP file and populates document values with its contents.
func (doc *XmpDocument) Load(filename string) error {
data, err := ioutil.ReadFile(filename)
@ -202,30 +209,81 @@ func (doc *XmpDocument) Load(filename string) error {
return xml.Unmarshal(data, doc)
}
// Title returns the XMP document title.
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 {
return SanitizeString(doc.RDF.Description.Creator.Seq.Li)
}
// Description returns the XMP document description.
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 {
return SanitizeString(doc.RDF.Description.Rights.Alt.Li.Text)
}
// CameraMake returns the XMP document camera make name.
func (doc *XmpDocument) CameraMake() string {
return SanitizeString(doc.RDF.Description.Make)
}
// CameraModel returns the XMP document camera model name.
func (doc *XmpDocument) CameraModel() string {
return SanitizeString(doc.RDF.Description.Model)
}
// LensModel returns the XMP document lens model name.
func (doc *XmpDocument) LensModel() string {
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 (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
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) {
data, err := XMP("testdata/photoshop.xmp")
@ -15,6 +29,8 @@ func TestXMP(t *testing.T) {
}
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, "Example file for development", data.Description)
assert.Equal(t, "This is an (edited) legal notice", data.Copyright)

View file

@ -13,61 +13,129 @@ import (
)
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 {
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)
if err != nil {
t.Fatal(err)
}
}
mainFile, err := NewMediaFile(filepath.Join(testPath, "2018-04-12 19_24_49.gif"))
testRelated, err := testFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
if err != nil {
t.Fatal(err)
}
related, err := mainFile.RelatedFiles(true)
testToken := rnd.Token(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
if err != nil {
t.Fatal(err)
}
for _, f := range testRelated.Files {
dest := filepath.Join(testPath, f.BaseName())
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
convert := NewConvert(conf)
if err := f.Copy(dest); err != nil {
t.Fatalf("copying test file failed: %s", err)
}
}
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
mainFile, err := NewMediaFile(filepath.Join(testPath, "2018-04-12 19_24_49.gif"))
result := IndexRelated(related, ind, opt)
if err != nil {
t.Fatal(err)
}
assert.False(t, result.Failed())
assert.False(t, result.Stacked())
assert.True(t, result.Success())
assert.Equal(t, IndexAdded, result.Status)
related, err := mainFile.RelatedFiles(true)
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)
}
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, "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>