feat(albums): --albums-from can be a list of patterns with special keywords

- %path expands to the path of the photo/video
- %keywords expands to the IPTC keywords of the photo
- {format} expands to the photo's EXIF date, e.g. {YYYY MM}
pull/93/head
Romain 7 years ago
parent 23f19566d0
commit 286dc8d15f

@ -67,14 +67,9 @@ const OPTIONS = {
'albums-from': {
group: 'Album options:',
description: 'How to group media into albums',
choices: ['folders', 'date'],
'default': 'folders'
},
'albums-date-format': {
group: 'Album options:',
description: 'How albums are named in <date> mode [moment.js pattern]',
'default': 'YYYY-MM'
description: 'How files are grouped into albums',
type: 'array',
'default': ['%path']
},
'sort-albums-by': {
group: 'Album options:',
@ -178,6 +173,11 @@ const OPTIONS = {
description: 'Copy and allow download of full-size videos',
type: 'boolean',
'default': false
},
'albums-date-format': {
group: 'Album options:',
description: 'How albums are named in <date> mode [moment.js pattern]',
'default': 'YYYY-MM'
}
}

@ -0,0 +1,41 @@
/*
--------------------------------------------------------------------------------
Returns all albums that a file should belong to,
based on the --albums-from array of patterns provided
--------------------------------------------------------------------------------
*/
const _ = require('lodash')
const albumPattern = require('./album-pattern')
class AlbumMapper {
constructor (opts) {
const patterns = opts.albumsFrom || '%path'
this.patterns = patterns.map(pattern => load(pattern, opts.albumsDateFormat))
}
getAlbums (file) {
return _.flatMap(this.patterns, pattern => pattern(file))
}
}
function load (pattern, dateFormat) {
// legacy short-hand names (deprecated)
if (pattern === 'folders') {
return albumPattern.create('%path')
}
if (pattern === 'date') {
return albumPattern.create(`{${dateFormat}}`)
}
// custom mapper file
if (typeof pattern === 'string' && pattern.startsWith('file://')) {
const filepath = pattern.slice('file://'.length)
return require(filepath)
}
if (typeof pattern === 'string') {
return albumPattern.create(pattern)
}
// already a function
return pattern
}
module.exports = AlbumMapper

@ -0,0 +1,51 @@
/*
--------------------------------------------------------------------------------
Returns a list of album names for every file.
Can be based on anything, e.g. directory name, date, metadata keywords...
e.g. `Holidays/London/IMG_00001.jpg` -> `Holidays/London`
--------------------------------------------------------------------------------
*/
const moment = require('moment')
const path = require('path')
const TOKEN_REGEX = /%[a-z]+/g
const DATE_REGEX = /{[^}]+}/g
const TOKEN_FUNC = {
'%path': file => path.dirname(file.path)
}
exports.create = pattern => {
const cache = {
usesTokens: TOKEN_REGEX.test(pattern),
usesDates: DATE_REGEX.test(pattern),
usesKeywords: pattern.indexOf('%keywords') > -1
}
// return a standard mapper function (file => album names)
return file => {
var album = pattern
// replace known tokens
if (cache.usesTokens) {
album = album.replace(TOKEN_REGEX, token => replaceToken(file, token))
}
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))
} else {
return [album]
}
}
}
function replaceToken (file, token) {
const fn = TOKEN_FUNC[token]
return fn ? fn(file) : token
}
function replaceDate (file, format) {
const fmt = format.slice(1, -1)
return moment(file.meta.date).format(fmt)
}

@ -19,13 +19,16 @@ function group (collection, mapper) {
'.': new Album('Home')
}
// put all files in the right albums
// a file can be in multiple albums
collection.forEach(function (file) {
var groupName = mapper(file)
if (!groupName || groupName === '/') {
groupName = '.'
}
createAlbumHierarchy(groups, groupName)
groups[groupName].files.push(file)
const albums = mapper.getAlbums(file)
albums.forEach(albumPath => {
if (!albumPath || albumPath === '/') {
albumPath = '.'
}
createAlbumHierarchy(groups, albumPath)
groups[albumPath].files.push(file)
})
})
// return the top-level album
return groups['.']

@ -1,23 +0,0 @@
/*
--------------------------------------------------------------------------------
Returns the target album path for a single file.
Can be based on anything, e.g. directory name, date, metadata keywords...
e.g. `Holidays/London/IMG_00001.jpg` -> `Holidays/London`
--------------------------------------------------------------------------------
*/
const moment = require('moment')
const path = require('path')
exports.create = function (opts) {
var mapper = null
if (opts.albumsFrom === 'folders') {
mapper = (file) => path.dirname(file.path)
} else if (opts.albumsFrom === 'date') {
var dateFormat = opts.albumsDateFormat || 'YYYY MMMM'
mapper = (file) => moment(file.meta.date).format(dateFormat)
} else {
throw new Error('Invalid <albumsFrom> option')
}
return mapper
}

@ -8,7 +8,7 @@ Caches the results in <thumbsup.db> for faster re-runs
const hierarchy = require('../input/hierarchy.js')
const Index = require('../components/index/index')
const info = require('debug')('thumbsup:info')
const mapper = require('../input/mapper')
const AlbumMapper = require('../input/album-mapper')
const Metadata = require('../model/metadata')
const File = require('../model/file')
const Observable = require('zen-observable')
@ -49,8 +49,8 @@ exports.run = function (opts, callback) {
// finished, we can create the albums
emitter.on('done', stats => {
const albumMapper = mapper.create(opts)
const album = hierarchy.createAlbums(files, albumMapper, opts)
const mapper = new AlbumMapper(opts)
const album = hierarchy.createAlbums(files, mapper, opts)
callback(null, files, album)
observer.complete()
})

@ -1,22 +0,0 @@
const Picasa = require('../input/picasa')
const mapper = require('../input/mapper')
const hierarchy = require('../input/hierarchy.js')
const File = require('../model/file')
const Metadata = require('../model/metadata')
exports.run = function (database, opts, callback) {
const picasaReader = new Picasa()
// create a flat array of files
const files = database.map(entry => {
// create standarised metadata model
const picasa = picasaReader.file(entry.SourceFile)
const meta = new Metadata(entry, picasa || {})
// create a file entry for the albums
return new File(entry, meta, opts)
})
// create the full album hierarchy
const albumMapper = mapper.create(opts)
const album = hierarchy.createAlbums(files, albumMapper, opts)
// return the results
return {files, album}
}

@ -5,14 +5,21 @@ const options = require('../../bin/options.js')
const BASE_ARGS = ['--input', 'photos', '--output', 'website']
describe('options', function () {
it('--input is converted to an absolute path', function () {
it('--input is converted to an absolute path', () => {
const opts = options.get(BASE_ARGS)
should(opts.input).eql(path.join(process.cwd(), 'photos'))
})
it('--output is converted to an absolute path', function () {
it('--output is converted to an absolute path', () => {
const opts = options.get(BASE_ARGS)
should(opts.output).eql(path.join(process.cwd(), 'website'))
})
describe('--albums-from', () => {
it('can be a single pattern value', () => {
const args = BASE_ARGS.concat(['--albums-from "%path"'])
const opts = options.get(args)
should(opts.albumsFrom).eql(['%path'])
})
})
describe('deprecated', () => {
it('--original-photos false', () => {
const args = BASE_ARGS.concat(['--original-photos false'])

@ -11,7 +11,9 @@ exports.exiftool = function (opts) {
MIMEType: opts.mimeType || 'image/jpg'
},
EXIF: {},
IPTC: {},
IPTC: {
Keywords: opts.keywords
},
XMP: {},
H264: {},
QuickTime: {}

@ -0,0 +1,53 @@
const should = require('should/as-function')
const AlbumMapper = require('../../src/input/album-mapper.js')
const fixtures = require('../fixtures.js')
const TEST_FILE = fixtures.photo({
path: 'Holidays/IMG_0001.jpg',
date: '2016:07:14 12:07:41'
})
describe('Album mapper', function () {
it('can use a single string pattern', function () {
const mapper = new AlbumMapper({
albumsFrom: ['%path']
})
should(mapper.getAlbums(TEST_FILE)).eql(['Holidays'])
})
it('can use a single function (for testing)', function () {
const custom = file => 'hello'
const mapper = new AlbumMapper({
albumsFrom: [custom]
})
should(mapper.getAlbums(TEST_FILE)).eql(['hello'])
})
it('can provide multiple string patterns', function () {
const mapper = new AlbumMapper({
albumsFrom: ['%path', '{YYYY}']
})
should(mapper.getAlbums(TEST_FILE)).eql(['Holidays', '2016'])
})
it('merges all albums into a single array', function () {
const custom1 = file => ['one']
const custom2 = file => ['two', 'three']
const mapper = new AlbumMapper({
albumsFrom: [custom1, custom2]
})
should(mapper.getAlbums(TEST_FILE)).eql(['one', 'two', 'three'])
})
describe('deprecated options', () => {
it('can use <folders> to mean %path', () => {
const mapper = new AlbumMapper({
albumsFrom: ['folders']
})
should(mapper.getAlbums(TEST_FILE)).eql(['Holidays'])
})
it('can use <date> to mean {date}', () => {
const mapper = new AlbumMapper({
albumsFrom: ['date'],
albumsDateFormat: 'YYYY MM'
})
should(mapper.getAlbums(TEST_FILE)).eql(['2016 07'])
})
})
})

@ -0,0 +1,98 @@
const should = require('should/as-function')
const pattern = require('../../src/input/album-pattern.js')
const fixtures = require('../fixtures.js')
describe('AlbumPattern', function () {
describe('text', () => {
it('can return a plain text album name', function () {
const func = pattern.create('Holidays/Canada')
const file = fixtures.photo()
should(func(file)).eql(['Holidays/Canada'])
})
it('can have extra text around keywords', function () {
const func = pattern.create('Holidays/%path')
const file = fixtures.photo({
path: 'Canada/IMG_0001.jpg'
})
should(func(file)).eql(['Holidays/Canada'])
})
})
describe('path', () => {
it('%path returns the relative path of the photo', function () {
const func = pattern.create('%path')
const file = fixtures.photo({
path: 'Holidays/IMG_0001.jpg'
})
should(func(file)).eql(['Holidays'])
})
it('%path includes all subfolders', function () {
const func = pattern.create('%path')
const file = fixtures.photo({
path: 'Holidays/Canada/IMG_0001.jpg'
})
should(func(file)).eql(['Holidays/Canada'])
})
})
describe('creation date', () => {
it('can use a moment.js format: {YYYY MM}', function () {
const func = pattern.create('{YYYY MM}')
const file = fixtures.photo({
date: '2016:07:14 12:07:41'
})
should(func(file)).eql(['2016 07'])
})
it('can include slashes in the format: {YYYY/MM}', function () {
const func = pattern.create('{YYYY/MM}')
const file = fixtures.photo({
date: '2016:07:14 12:07:41'
})
should(func(file)).eql(['2016/07'])
})
it('can have multiple dates in the same pattern: {YYYY}/{MM}', function () {
const func = pattern.create('{YYYY}/{MM}')
const file = fixtures.photo({
date: '2016:07:14 12:07:41'
})
should(func(file)).eql(['2016/07'])
})
})
describe('keywords', () => {
it('can return a single keyword', () => {
const func = pattern.create('%keywords')
const file = fixtures.photo({
keywords: ['beach']
})
should(func(file)).eql(['beach'])
})
it('can return multiple keyword', () => {
const func = pattern.create('%keywords')
const file = fixtures.photo({
keywords: ['beach', 'sunset']
})
should(func(file)).eql(['beach', 'sunset'])
})
it('can use plain text around the keywords', () => {
const func = pattern.create('Tags/%keywords')
const file = fixtures.photo({
keywords: ['beach', 'sunset']
})
should(func(file)).eql(['Tags/beach', 'Tags/sunset'])
})
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('Complex patterns', () => {
it('can mix several tokens inside a complex pattern', () => {
const func = pattern.create('{YYYY}/%path/%keywords')
const file = fixtures.photo({
path: 'Holidays/IMG_0001.jpg',
date: '2016:07:14 12:07:41',
keywords: ['beach', 'sunset']
})
should(func(file)).eql(['2016/Holidays/beach', '2016/Holidays/sunset'])
})
})
})

@ -11,20 +11,20 @@ describe('hierarchy', function () {
describe('root album', function () {
it('creates a root album (homepage) to put all sub-albums', function () {
const mapper = (file) => 'all'
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums([], mapper, {})
should(home.title).eql('Home')
})
it('defaults the homepage to index.html', function () {
const mapper = (file) => 'all'
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums([], mapper, {})
should(home.path).eql('index.html')
should(home.url).eql('index.html')
})
it('can configure the homepage path', function () {
const mapper = (file) => 'all'
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums([], mapper, {index: 'default.html'})
should(home.path).eql('default.html')
should(home.url).eql('default.html')
@ -39,7 +39,7 @@ describe('hierarchy', function () {
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = file => value
const mapper = mockMapper(file => [value])
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(0)
should(home.files.length).eql(2)
@ -55,7 +55,7 @@ describe('hierarchy', function () {
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = (file) => 'all'
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('all')
@ -67,7 +67,7 @@ describe('hierarchy', function () {
fixtures.photo({path: 'one/IMG_000001.jpg'}),
fixtures.photo({path: 'two/IMG_000002.jpg'})
]
const mapper = (file) => path.dirname(file.path)
const mapper = mockMapper(file => [path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(2)
should(home.albums[0].title).eql('one')
@ -81,7 +81,7 @@ describe('hierarchy', function () {
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = (file) => 'one/two'
const mapper = mockMapper(file => ['one/two'])
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
@ -95,7 +95,7 @@ describe('hierarchy', function () {
fixtures.photo({path: 'one/IMG_000001.jpg'}),
fixtures.photo({path: 'one/two/IMG_000002.jpg'})
]
const mapper = (file) => path.dirname(file.path)
const mapper = mockMapper(file => [path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
@ -106,3 +106,9 @@ describe('hierarchy', function () {
})
})
})
function mockMapper (fn) {
return {
getAlbums: fn
}
}

@ -1,32 +0,0 @@
var should = require('should/as-function')
var mapper = require('../../src/input/mapper.js')
var fixtures = require('../fixtures.js')
describe('mapper', function () {
it('can create a path mapper', function () {
const map = mapper.create({albumsFrom: 'folders'})
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg'
})
should(map(entry)).eql('holidays/canada')
})
it('can create a default date mapper', function () {
const map = mapper.create({albumsFrom: 'date'})
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg',
date: '2016:07:14 12:07:41'
})
should(map(entry)).eql('2016 July')
})
it('can create a custom date mapper', function () {
const map = mapper.create({
albumsFrom: 'date',
albumsDateFormat: 'YYYY/MM'
})
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg',
date: '2016:07:14 12:07:41'
})
should(map(entry)).eql('2016/07')
})
})
Loading…
Cancel
Save