diff --git a/bin/options.js b/bin/options.js index c2e625f..f0b7e57 100644 --- a/bin/options.js +++ b/bin/options.js @@ -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 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 mode [moment.js pattern]', + 'default': 'YYYY-MM' } } diff --git a/src/input/album-mapper.js b/src/input/album-mapper.js new file mode 100644 index 0000000..7960f25 --- /dev/null +++ b/src/input/album-mapper.js @@ -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 diff --git a/src/input/album-pattern.js b/src/input/album-pattern.js new file mode 100644 index 0000000..62da981 --- /dev/null +++ b/src/input/album-pattern.js @@ -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) +} diff --git a/src/input/hierarchy.js b/src/input/hierarchy.js index dbdb178..eee8ce4 100644 --- a/src/input/hierarchy.js +++ b/src/input/hierarchy.js @@ -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['.'] diff --git a/src/input/mapper.js b/src/input/mapper.js deleted file mode 100644 index 208375e..0000000 --- a/src/input/mapper.js +++ /dev/null @@ -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 option') - } - return mapper -} diff --git a/src/steps/step-index.js b/src/steps/step-index.js index a3b0488..3fb65e2 100644 --- a/src/steps/step-index.js +++ b/src/steps/step-index.js @@ -8,7 +8,7 @@ Caches the results in 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() }) diff --git a/src/steps/step-model.js b/src/steps/step-model.js deleted file mode 100644 index a7efe36..0000000 --- a/src/steps/step-model.js +++ /dev/null @@ -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} -} diff --git a/test/bin/options.spec.js b/test/bin/options.spec.js index f580bc2..a87e4e6 100644 --- a/test/bin/options.spec.js +++ b/test/bin/options.spec.js @@ -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']) diff --git a/test/fixtures.js b/test/fixtures.js index e9f4747..4a73cef 100644 --- a/test/fixtures.js +++ b/test/fixtures.js @@ -11,7 +11,9 @@ exports.exiftool = function (opts) { MIMEType: opts.mimeType || 'image/jpg' }, EXIF: {}, - IPTC: {}, + IPTC: { + Keywords: opts.keywords + }, XMP: {}, H264: {}, QuickTime: {} diff --git a/test/input/album-mapper.spec.js b/test/input/album-mapper.spec.js new file mode 100644 index 0000000..a2be7b5 --- /dev/null +++ b/test/input/album-mapper.spec.js @@ -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 to mean %path', () => { + const mapper = new AlbumMapper({ + albumsFrom: ['folders'] + }) + should(mapper.getAlbums(TEST_FILE)).eql(['Holidays']) + }) + it('can use to mean {date}', () => { + const mapper = new AlbumMapper({ + albumsFrom: ['date'], + albumsDateFormat: 'YYYY MM' + }) + should(mapper.getAlbums(TEST_FILE)).eql(['2016 07']) + }) + }) +}) diff --git a/test/input/album-pattern.spec.js b/test/input/album-pattern.spec.js new file mode 100644 index 0000000..3abc342 --- /dev/null +++ b/test/input/album-pattern.spec.js @@ -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']) + }) + }) +}) diff --git a/test/input/hierarchy.spec.js b/test/input/hierarchy.spec.js index 56e83b0..fcb81c1 100644 --- a/test/input/hierarchy.spec.js +++ b/test/input/hierarchy.spec.js @@ -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 + } +} diff --git a/test/input/mapper.spec.js b/test/input/mapper.spec.js deleted file mode 100644 index 5a19b4d..0000000 --- a/test/input/mapper.spec.js +++ /dev/null @@ -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') - }) -})