Options to specify EXIF tags where keywords and "people in image" are found (#224)

* Add dynamic keyword and people-in-photo handling.
Add new options include-keywords, exclude-keyword.
Add new options include-people, exclude-people.
pull/241/head
Geoffrey Lowney 3 years ago committed by GitHub
parent 84a46c16b7
commit cebb0327fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -238,6 +238,36 @@ const OPTIONS = {
type: 'boolean',
'default': false
},
// 'keyword-fields': {
// group: 'Album options:',
// description: 'Where to look in the metadata data for keywords (for %keywords)',
// type: 'array'
// },
'include-keywords': {
group: 'Album options:',
description: 'Keywords to include in %keywords',
type: 'array'
},
'exclude-keywords': {
group: 'Album options:',
description: 'Keywords to exclude from %keywords',
type: 'array'
},
// 'people-fields': {
// group: 'Album options:',
// description: 'Where to look in the metadata data for people names (for %people)',
// type: 'array'
// },
'include-people': {
group: 'Album options:',
description: 'Names to include in %people',
type: 'array'
},
'exclude-people': {
group: 'Album options:',
description: 'Names to exclude from %people',
type: 'array'
},
'album-previews': {
group: 'Album options:',
description: 'How previews are selected',
@ -439,6 +469,15 @@ exports.get = (args) => {
opts.logFile = changeExtension(opts.databaseFile, '.log')
}
// Default keyword fields
if (!opts.keywordFields) {
opts.keywordFields = ['XMP.Subject', 'IPTC.Keywords', 'Picasa:Keywords']
}
// Default people fields
if (!opts.peopleFields) {
opts.peopleFields = ['XMP.PersonInImage']
}
// Better to work with absolute paths
opts.input = path.resolve(opts.input)
opts.output = path.resolve(opts.output)

6891
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -10,16 +10,16 @@ const path = require('path')
const albumPattern = require('./album-pattern')
class AlbumMapper {
constructor (patterns) {
constructor (patterns, opts) {
const defaulted = (patterns && patterns.length > 0) ? patterns : ['%path']
this.patterns = defaulted.map(load)
this.patterns = defaulted.map(p => load(p, opts))
}
getAlbums (file) {
return _.flatMap(this.patterns, pattern => pattern(file))
}
}
function load (pattern) {
function load (pattern, opts) {
// custom mapper file
if (typeof pattern === 'string' && pattern.startsWith('file://')) {
const filepath = pattern.slice('file://'.length)
@ -27,7 +27,7 @@ function load (pattern) {
}
// string pattern
if (typeof pattern === 'string') {
return albumPattern.create(pattern)
return albumPattern.create(pattern, opts)
}
// already a function
return pattern

@ -15,13 +15,19 @@ const TOKEN_FUNC = {
'%path': file => path.dirname(file.path)
}
exports.create = pattern => {
exports.create = (pattern, opts) => {
const cache = {
usesTokens: TOKEN_REGEX.test(pattern),
usesDates: DATE_REGEX.test(pattern),
usesKeywords: pattern.indexOf('%keywords') > -1
usesKeywords: pattern.indexOf('%keywords') > -1,
usesPeople: pattern.indexOf('%people') > -1
}
// return a standard mapper function (file => album names)
return mapperFunction(pattern, cache, opts)
}
function mapperFunction (pattern, cache, opts) {
if (opts === undefined) { opts = {} }
return file => {
var album = pattern
// replace known tokens
@ -31,15 +37,23 @@ exports.create = pattern => {
if (cache.usesDates) {
album = album.replace(DATE_REGEX, format => replaceDate(file, format))
}
// create one album per keyword if required
if (cache.usesKeywords) {
return file.meta.keywords.map(k => album.replace('%keywords', k))
// create one album per keyword
return replaceTags(file.meta.keywords, { includes: opts.includeKeywords, excludes: opts.excludeKeywords }, album, '%keywords')
} else if (cache.usesPeople) {
// create one album per person
return replaceTags(file.meta.people, { includes: opts.includePeople, excludes: opts.excludePeople }, album, '%people')
} else {
return [album]
}
}
}
function replaceTags (words, filter, album, tag) {
words = filterWords(words, filter)
return words.map(k => album.replace(tag, k))
}
function replaceToken (file, token) {
const fn = TOKEN_FUNC[token]
return fn ? fn(file) : token
@ -49,3 +63,18 @@ function replaceDate (file, format) {
const fmt = format.slice(1, -1)
return moment(file.meta.date).format(fmt)
}
function filterWords (words, filter) {
const { includes, excludes } = filter
if (includes && includes.length > 0) words = setIntersection(words, includes)
if (excludes && excludes.length > 0) words = setDifference(words, excludes)
return words
}
function setDifference (words, excludeWords) {
return words.filter(x => !excludeWords.includes(x))
}
function setIntersection (words, includeWords) {
return words.filter(x => includeWords.includes(x))
}

@ -25,7 +25,8 @@ class Metadata {
// standardise metadata
this.date = getDate(exiftool)
this.caption = caption(exiftool)
this.keywords = keywords(exiftool, picasa)
this.keywords = keywords(exiftool, picasa, opts)
this.people = people(exiftool, opts)
this.video = video(exiftool)
this.animated = animated(exiftool)
this.rating = rating(exiftool)
@ -56,10 +57,10 @@ function getDate (exif) {
function getMetaDate (exif) {
const date = tagValue(exif, 'EXIF', 'DateTimeOriginal') ||
tagValue(exif, 'H264', 'DateTimeOriginal') ||
tagValue(exif, 'QuickTime', 'ContentCreateDate') ||
tagValue(exif, 'QuickTime', 'CreationDate') ||
tagValue(exif, 'QuickTime', 'CreateDate')
tagValue(exif, 'H264', 'DateTimeOriginal') ||
tagValue(exif, 'QuickTime', 'ContentCreateDate') ||
tagValue(exif, 'QuickTime', 'CreationDate') ||
tagValue(exif, 'QuickTime', 'CreateDate')
if (date) {
const parsed = moment(date, EXIF_DATE_FORMAT)
if (parsed.isValid()) return parsed
@ -78,26 +79,45 @@ function getFilenameDate (exif) {
function caption (exif, picasa) {
return picasaValue(picasa, 'caption') ||
tagValue(exif, 'EXIF', 'ImageDescription') ||
tagValue(exif, 'IPTC', 'Caption-Abstract') ||
tagValue(exif, 'IPTC', 'Headline') ||
tagValue(exif, 'XMP', 'Description') ||
tagValue(exif, 'XMP', 'Title') ||
tagValue(exif, 'XMP', 'Label') ||
tagValue(exif, 'QuickTime', 'Title')
}
function keywords (exif, picasa) {
// try Picasa (comma-separated)
const picasaValues = picasaValue(picasa, 'keywords')
if (picasaValues) return picasaValues.split(',')
// try IPTC (string or array)
const iptcValues = tagValue(exif, 'IPTC', 'Keywords')
if (iptcValues) return makeArray(iptcValues)
// no keywords
tagValue(exif, 'EXIF', 'ImageDescription') ||
tagValue(exif, 'IPTC', 'Caption-Abstract') ||
tagValue(exif, 'IPTC', 'Headline') ||
tagValue(exif, 'XMP', 'Description') ||
tagValue(exif, 'XMP', 'Title') ||
tagValue(exif, 'XMP', 'Label') ||
tagValue(exif, 'QuickTime', 'Title')
}
function keywords (exif, picasa, opts) {
if (opts && opts.keywordFields) {
return findTags(opts.keywordFields, exif, picasa)
}
return []
}
function people (exif, opts) {
if (opts && opts.peopleFields) {
return findTags(opts.peopleFields, exif)
}
return []
}
function findTags (fields, exif, picasa) {
var words = new Set()
fields.forEach(field => {
const fieldComponents = field.split(/[.:]/, 2)
let values = []
if (/Picasa/i.test(fieldComponents[0])) {
const valuesString = picasaValue(picasa, fieldComponents[1])
if (valuesString) { values = valuesString.split(',') } // Picasa comma-separated
} else {
values = tagValue(exif, ...fieldComponents)
}
if (values) makeArray(values).forEach(word => words.add(word)) // value could be string or array
})
return [...words]
}
function video (exif) {
return MIME_VIDEO_REGEX.test(exif.File['MIMEType'])
}

@ -48,7 +48,7 @@ exports.run = function (opts, callback) {
// finished, we can create the albums
emitter.on('done', stats => {
const mapper = new AlbumMapper(opts.albumsFrom)
const mapper = new AlbumMapper(opts.albumsFrom, opts)
const album = hierarchy.createAlbums(files, mapper, opts)
callback(null, files, album)
observer.complete()

@ -18,7 +18,10 @@ exports.exiftool = function (opts) {
IPTC: {
Keywords: opts.keywords
},
XMP: {},
XMP: {
PersonInImage: opts.people,
Subject: opts.subjects
},
H264: {},
QuickTime: {}
}
@ -28,9 +31,9 @@ exports.metadata = function (opts) {
return new Metadata(exports.exiftool(opts))
}
exports.file = function (opts) {
const exiftool = exports.exiftool(opts)
const meta = new Metadata(exiftool)
exports.file = function (fileOpts, opts) {
const exiftool = exports.exiftool(fileOpts)
const meta = new Metadata(exiftool, undefined, opts)
return new File(exiftool, meta)
}
@ -39,14 +42,14 @@ exports.date = function (str) {
// return new Date(Date.parse(str))
}
exports.photo = function (opts) {
if (typeof opts === 'string') {
opts = { path: opts }
exports.photo = function (photoOpts, opts) {
if (typeof photoOpts === 'string') {
photoOpts = { path: photoOpts }
} else {
opts = opts || {}
photoOpts = photoOpts || {}
}
opts.mimeType = 'image/jpg'
return exports.file(opts)
photoOpts.mimeType = 'image/jpg'
return exports.file(photoOpts, opts)
}
exports.video = function (opts) {

@ -57,41 +57,114 @@ describe('AlbumPattern', function () {
})
})
describe('keywords', () => {
const opts = {
keywordFields: ['IPTC:Keywords']
}
it('can return a single keyword', () => {
const func = pattern.create('%keywords')
const func = pattern.create('%keywords', opts)
const file = fixtures.photo({
keywords: ['beach']
})
}, opts)
should(func(file)).eql(['beach'])
})
it('can return multiple keyword', () => {
const func = pattern.create('%keywords')
const func = pattern.create('%keywords', opts)
const file = fixtures.photo({
keywords: ['beach', 'sunset']
})
}, opts)
should(func(file)).eql(['beach', 'sunset'])
})
it('can use plain text around the keywords', () => {
const func = pattern.create('Tags/%keywords')
const func = pattern.create('Tags/%keywords', opts)
const file = fixtures.photo({
keywords: ['beach', 'sunset']
})
}, opts)
should(func(file)).eql(['Tags/beach', 'Tags/sunset'])
})
it('can find keywords in a specified tag', () => {
const func = pattern.create('%keywords')
const file = fixtures.photo({
subjects: ['sunny beach']
}, {
keywordFields: ['XMP.Subject']
})
should(func(file)).eql(['sunny beach'])
})
it('can deal with keyword includes and excludes', () => {
const opts = {
keywordFields: ['XMP.Subject'],
includeKeywords: ['sunny beach', 'sandy shore', 'waves'],
excludeKeywords: ['sandy shore']
}
const func = pattern.create('%keywords', opts)
const file = fixtures.photo({
subjects: ['beach', 'sunny beach', 'sandy shore', 'waves']
}, opts)
should(func(file)).eql(['sunny beach', 'waves'])
})
it('does not return any albums if the photo does not have keywords', () => {
const func = pattern.create('{YYYY}/tags/%keywords')
const file = fixtures.photo()
should(func(file)).eql([])
})
})
describe('people', () => {
it('can return a single person', () => {
const func = pattern.create('%people')
const file = fixtures.photo({
people: ['john doe']
}, {
peopleFields: ['XMP.PersonInImage']
})
should(func(file)).eql(['john doe'])
})
it('can return multiple people', () => {
const func = pattern.create('%people')
const file = fixtures.photo({
people: ['john doe', 'jane doe']
}, {
peopleFields: ['XMP.PersonInImage']
})
should(func(file)).eql(['john doe', 'jane doe'])
})
it('can use plain text around the people', () => {
const func = pattern.create('Tags/%people')
const file = fixtures.photo({
people: ['john doe', 'jane doe']
}, {
peopleFields: ['XMP.PersonInImage']
})
should(func(file)).eql(['Tags/john doe', 'Tags/jane doe'])
})
it('can deal with people includes and excludes', () => {
const opts = {
peopleFields: ['XMP.PersonInImage'],
includePeople: ['jane doe', 'john lennon', 'paul mccartney'],
excludePeople: ['john lennon']
}
const func = pattern.create('%people', opts)
const file = fixtures.photo({
people: ['john doe', 'jane doe', 'john lennon', 'paul mccartney']
}, opts)
should(func(file)).eql(['jane doe', 'paul mccartney'])
})
it('does not return any albums if the photo does not have people', () => {
const func = pattern.create('{YYYY}/tags/%people')
const file = fixtures.photo()
should(func(file)).eql([])
})
})
describe('Complex patterns', () => {
const opts = {
keywordFields: ['IPTC:Keywords']
}
it('can mix several tokens inside a complex pattern', () => {
const func = pattern.create('{YYYY}/%path/%keywords')
const func = pattern.create('{YYYY}/%path/%keywords', opts)
const file = fixtures.photo({
path: 'Holidays/IMG_0001.jpg',
date: '2016:07:14 12:07:41',
keywords: ['beach', 'sunset']
})
}, opts)
should(func(file)).eql(['2016/Holidays/beach', '2016/Holidays/sunset'])
})
})

@ -151,6 +151,13 @@ describe('Metadata', function () {
})
describe('keywords', function () {
const picasaOpts = {
keywordFields: ['Picasa:keywords']
}
const iptcOpts = {
keywordFields: ['IPTC:Keywords']
}
it('defaults to an empty array', function () {
const exiftool = fixtures.exiftool()
const meta = new Metadata(exiftool)
@ -161,7 +168,7 @@ describe('Metadata', function () {
// a single keyword is returned as a string by <exiftool>
const exiftool = fixtures.exiftool()
exiftool.IPTC['Keywords'] = 'beach'
const meta = new Metadata(exiftool)
const meta = new Metadata(exiftool, {}, iptcOpts)
should(meta.keywords).eql(['beach'])
})
@ -169,14 +176,14 @@ describe('Metadata', function () {
// multiple keywords are returned as an array by <exiftool>
const exiftool = fixtures.exiftool()
exiftool.IPTC['Keywords'] = ['beach', 'sunset']
const meta = new Metadata(exiftool)
const meta = new Metadata(exiftool, {}, iptcOpts)
should(meta.keywords).eql(['beach', 'sunset'])
})
it('can read a single Picasa keywords', function () {
const exiftool = fixtures.exiftool()
const picasa = { keywords: 'beach' }
const meta = new Metadata(exiftool, picasa)
const meta = new Metadata(exiftool, picasa, picasaOpts)
should(meta.keywords).eql(['beach'])
})
@ -184,7 +191,7 @@ describe('Metadata', function () {
// because it's a simple INI file, multiple keywords are comma-separated
const exiftool = fixtures.exiftool()
const picasa = { keywords: 'beach,sunset' }
const meta = new Metadata(exiftool, picasa)
const meta = new Metadata(exiftool, picasa, picasaOpts)
should(meta.keywords).eql(['beach', 'sunset'])
})
})

Loading…
Cancel
Save