Create a Metadata model attached to input files

- for easier unit testing
- to enable input filtering (e.g. only include photos with this keyword)
pull/77/head
Romain 7 years ago
parent 5074fb267f
commit 3e64d2ab38

@ -59,6 +59,7 @@
"afterEach",
"beforeEach",
"describe",
"xdescribe",
"it",
"xit"
]

@ -5,10 +5,10 @@ const os = require('os')
const cleanup = require('./output-media/cleanup')
const database = require('./input/database')
const progress = require('./utils/progress')
const File = require('./input/file')
const hierarchy = require('./model/hierarchy.js')
const mapper = require('./model/mapper')
const Media = require('./model/media')
const hierarchy = require('./input/hierarchy.js')
const mapper = require('./input/mapper')
const File = require('./model/file')
const Metadata = require('./model/metadata')
const tasks = require('./output-media/tasks')
const website = require('./output-website/website')
@ -16,41 +16,47 @@ exports.build = function (opts) {
fs.mkdirpSync(opts.output)
const databaseFile = path.join(opts.output, 'metadata.json')
var album = null // root album with nested albums
var fileCollection = null // all files in the database
// all files, unsorted
var files = null
// root album with nested albums
var album = null
async.series([
function updateDatabase (callback) {
database.update(opts.input, databaseFile, (err, dbFiles) => {
database.update(opts.input, databaseFile, (err, entries) => {
if (err) return callback(err)
fileCollection = dbFiles.map(f => new File(f, opts))
files = entries.map(entry => {
// create standarised metadata model
const meta = new Metadata(entry)
return new File(entry, meta, opts)
})
callback()
})
},
function processPhotos (callback) {
const photos = tasks.create(opts, fileCollection, 'image')
const photos = tasks.create(opts, files, 'image')
const bar = progress.create('Processing photos', photos.length)
parallel(photos, bar, callback)
},
function processVideos (callback) {
const videos = tasks.create(opts, fileCollection, 'video')
const videos = tasks.create(opts, files, 'video')
const bar = progress.create('Processing videos', videos.length)
parallel(videos, bar, callback)
},
function removeOldOutput (callback) {
if (!opts.cleanup) return callback()
cleanup.run(fileCollection, opts.output, callback)
cleanup.run(files, opts.output, callback)
},
function createAlbums (callback) {
const bar = progress.create('Creating albums')
const albumMapper = mapper.create(opts)
const mediaCollection = fileCollection.map(f => new Media(f))
album = hierarchy.createAlbums(mediaCollection, albumMapper, opts)
album = hierarchy.createAlbums(files, albumMapper, opts)
bar.tick(1)
callback()
},

@ -1,3 +1,10 @@
/*
--------------------------------------------------------------------------------
Provides most metadata based on the output of <exiftool>
Caches the resulting DB in <metadata.json> for faster re-runs
--------------------------------------------------------------------------------
*/
const debug = require('debug')('thumbsup')
const exifdb = require('exiftool-json-db')
const progress = require('../utils/progress')

@ -1,30 +0,0 @@
const moment = require('moment')
const output = require('./output')
const MIME_REGEX = /([^/]+)\/(.*)/
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
/*
Represents a source file on disk and how it maps to output files
+ all known metadata
*/
function File (dbEntry, opts) {
this.meta = dbEntry
this.path = dbEntry.SourceFile
this.date = fileDate(dbEntry)
this.type = mediaType(dbEntry)
this.output = output.paths(this.path, this.type, opts || {})
}
function fileDate (dbEntry) {
return moment(dbEntry.File.FileModifyDate, EXIF_DATE_FORMAT).valueOf()
}
function mediaType (dbEntry) {
const match = MIME_REGEX.exec(dbEntry.File.MIMEType)
if (match && match[1] === 'image') return 'image'
if (match && match[1] === 'video') return 'video'
return 'unknown'
}
module.exports = File

@ -1,5 +1,5 @@
const path = require('path')
const Album = require('./album')
const Album = require('../model/album')
exports.createAlbums = function (collection, mapper, opts) {
// returns a top-level album for the home page
@ -20,13 +20,13 @@ function group (collection, mapper) {
'.': new Album('Home')
}
// put all files in the right albums
collection.forEach(function (media) {
var groupName = mapper(media)
collection.forEach(function (file) {
var groupName = mapper(file)
if (!groupName) {
groupName = '.'
}
createAlbumHierarchy(groups, groupName)
groups[groupName].files.push(media)
groups[groupName].files.push(file)
})
// return the top-level album
return groups['.']
@ -42,9 +42,7 @@ function createAlbumHierarchy (allGroupNames, segment) {
// then create album if it doesn't exist
// and attach it to its parent
var lastSegment = path.basename(segment)
if (!allGroupNames.hasOwnProperty(segment)) {
allGroupNames[segment] = new Album({title: lastSegment})
allGroupNames[parent].albums.push(allGroupNames[segment])
}
allGroupNames[segment] = new Album({title: lastSegment})
allGroupNames[parent].albums.push(allGroupNames[segment])
}
}

@ -0,0 +1,23 @@
/*
--------------------------------------------------------------------------------
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
}

@ -1,23 +1,31 @@
var _ = require('lodash')
var path = require('path')
var url = require('url')
/*
--------------------------------------------------------------------------------
Represents an album, which is made of many photos and videos
This is a virtual grouping of files, independent of the location on disk
A single photo/video could exist in multiple albums
--------------------------------------------------------------------------------
*/
const _ = require('lodash')
const path = require('path')
const url = require('url')
var index = 0
// number of images to show in the album preview grid
var PREVIEW_COUNT = 10
const PREVIEW_COUNT = 10
var SORT_ALBUMS_BY = {
const SORT_ALBUMS_BY = {
'title': function (album) { return album.title },
'start-date': function (album) { return album.stats.fromDate },
'end-date': function (album) { return album.stats.toDate }
}
var SORT_MEDIA_BY = {
'filename': function (media) { return media.filename },
'date': function (media) { return media.date }
const SORT_MEDIA_BY = {
'filename': function (file) { return file.filename },
'date': function (file) { return file.meta.date }
}
var PREVIEW_MISSING = {
const PREVIEW_MISSING = {
urls: {
thumbnail: 'public/missing.png'
}
@ -34,7 +42,6 @@ function Album (opts) {
this.home = false
this.stats = null
this.previews = null
this.allFiles = []
}
Album.prototype.finalize = function (options, parent) {
@ -64,7 +71,6 @@ Album.prototype.finalize = function (options, parent) {
this.calculateSummary()
this.sort(options)
this.pickPreviews()
this.aggregateAllFiles()
}
Album.prototype.calculateStats = function () {
@ -74,10 +80,10 @@ Album.prototype.calculateStats = function () {
var nestedFromDates = _.map(this.albums, 'stats.fromDate')
var nestedToDates = _.map(this.albums, 'stats.toDate')
// current level
var currentPhotos = _.filter(this.files, {isVideo: false}).length
var currentVideos = _.filter(this.files, {isVideo: true}).length
var currentFromDate = _.map(this.files, 'date')
var currentToDate = _.map(this.files, 'date')
var currentPhotos = _.filter(this.files, {type: 'image'}).length
var currentVideos = _.filter(this.files, {type: 'video'}).length
var currentFromDate = _.map(this.files, 'meta.date')
var currentToDate = _.map(this.files, 'meta.date')
// aggregate all stats
this.stats = {
albums: this.albums.length,
@ -118,11 +124,6 @@ Album.prototype.pickPreviews = function () {
}
}
Album.prototype.aggregateAllFiles = function () {
var nestedFiles = _.flatten(_.map(this.albums, 'allFiles'))
this.allFiles = _.concat(nestedFiles, this.files)
}
function sanitise (filename) {
return filename.replace(/[^a-z0-9-_]/ig, '')
}

@ -0,0 +1,43 @@
/*
--------------------------------------------------------------------------------
Represents a file on disk, inside the input folder
Also includes how it maps to the different output files
--------------------------------------------------------------------------------
*/
const _ = require('lodash')
const path = require('path')
const moment = require('moment')
const output = require('./output')
const MIME_REGEX = /([^/]+)\/(.*)/
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
var index = 0
class File {
constructor (dbEntry, meta, opts) {
this.id = ++index
this.path = dbEntry.SourceFile
this.filename = path.basename(dbEntry.SourceFile)
this.date = fileDate(dbEntry)
this.type = mediaType(dbEntry)
this.isVideo = (this.type === 'video')
this.output = output.paths(this.path, this.type, opts || {})
this.urls = _.mapValues(this.output, o => o.path.replace('\\', '/'))
this.meta = meta
}
}
function fileDate (dbEntry) {
return moment(dbEntry.File.FileModifyDate, EXIF_DATE_FORMAT).valueOf()
}
function mediaType (dbEntry) {
const match = MIME_REGEX.exec(dbEntry.File.MIMEType)
if (match && match[1] === 'image') return 'image'
if (match && match[1] === 'video') return 'video'
return 'unknown'
}
module.exports = File

@ -1,15 +0,0 @@
const moment = require('moment')
const path = require('path')
exports.create = function (opts) {
var mapper = null
if (opts.albumsFrom === 'folders') {
mapper = (media) => path.dirname(media.file.path)
} else if (opts.albumsFrom === 'date') {
var dateFormat = opts.albumsDateFormat || 'YYYY MMMM'
mapper = (media) => moment(media.date).format(dateFormat)
} else {
throw new Error('Invalid <albumsFrom> option')
}
return mapper
}

@ -1,77 +0,0 @@
const _ = require('lodash')
const moment = require('moment')
const path = require('path')
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
// infer dates from files with a date-looking filename
const FILENAME_DATE_REGEX = /\d{4}[_\-.\s]?(\d{2}[_\-.\s]?){5}\..{3,4}/
// moment ignores non-numeric characters when parsing
const FILENAME_DATE_FORMAT = 'YYYYMMDD HHmmss'
var index = 0
/*
View model for album entries
*/
function Media (file) {
this.id = ++index
this.file = file
this.filename = path.basename(file.path)
this.urls = _.mapValues(file.output, o => o.path)
this.date = getDate(file)
this.caption = caption(file)
this.isVideo = (file.type === 'video')
this.isAnimated = animated(file)
this.rating = rating(file)
// view model could also include fields like
// - country = "England"
// - city = "London"
// - exif summary = [
// { field: "Aperture", icon: "fa-camera", value: "1.8" }
// ]
}
function getDate (file) {
const date = tagValue(file, 'EXIF', 'DateTimeOriginal') ||
tagValue(file, 'H264', 'DateTimeOriginal') ||
tagValue(file, 'QuickTime', 'CreationDate')
if (date) {
return moment(date, EXIF_DATE_FORMAT).valueOf()
} else {
const filename = path.basename(file.path)
if (FILENAME_DATE_REGEX.test(filename)) {
const namedate = moment(filename, FILENAME_DATE_FORMAT)
if (namedate.isValid()) return namedate.valueOf()
}
return file.date
}
}
function caption (file) {
return tagValue(file, 'EXIF', 'ImageDescription') ||
tagValue(file, 'IPTC', 'Caption-Abstract') ||
tagValue(file, 'IPTC', 'Headline') ||
tagValue(file, 'XMP', 'Description') ||
tagValue(file, 'XMP', 'Title') ||
tagValue(file, 'XMP', 'Label')
}
function animated (file) {
if (file.meta.File['MIMEType'] !== 'image/gif') return false
if (file.meta.GIF && file.meta.GIF.FrameCount > 0) return true
return false
}
function rating (file) {
if (!file.meta.XMP) return 0
return file.meta.XMP['Rating'] || 0
}
function tagValue (file, type, name) {
if (!file.meta[type]) return null
return file.meta[type][name]
}
module.exports = Media

@ -0,0 +1,91 @@
/*
--------------------------------------------------------------------------------
Standardised metadata for a given image or video
This is based on parsing "provider data" such as Exiftool or Picasa
--------------------------------------------------------------------------------
*/
const moment = require('moment')
const path = require('path')
// mime type for videos
const MIME_VIDEO_REGEX = /^video\/.*$/
// standard EXIF date format, which is different from ISO8601
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
// infer dates from files with a date-looking filename
const FILENAME_DATE_REGEX = /\d{4}[_\-.\s]?(\d{2}[_\-.\s]?){5}\..{3,4}/
// moment ignores non-numeric characters when parsing
const FILENAME_DATE_FORMAT = 'YYYYMMDD HHmmss'
class Metadata {
constructor (exiftool) {
// standardise metadata
this.date = getDate(exiftool)
this.caption = caption(exiftool)
this.keywords = keywords(exiftool)
this.video = video(exiftool)
this.animated = animated(exiftool)
this.rating = rating(exiftool)
// metadata could also include fields like
// - lat = 51.5
// - long = 0.12
// - country = "England"
// - city = "London"
// - aperture = 1.8
}
}
function getDate (exif) {
const date = tagValue(exif, 'EXIF', 'DateTimeOriginal') ||
tagValue(exif, 'H264', 'DateTimeOriginal') ||
tagValue(exif, 'QuickTime', 'CreationDate')
if (date) {
return moment(date, EXIF_DATE_FORMAT).valueOf()
} else {
const filename = path.basename(exif.SourceFile)
if (FILENAME_DATE_REGEX.test(filename)) {
const namedate = moment(filename, FILENAME_DATE_FORMAT)
if (namedate.isValid()) return namedate.valueOf()
}
return moment(exif.File.FileModifyDate, EXIF_DATE_FORMAT).valueOf()
}
}
function caption (exif, picasa) {
return 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')
}
function keywords (exif, picasa) {
const values = tagValue(exif, 'IPTC', 'Keywords')
return values ? values.split(',') : []
}
function video (exif) {
return MIME_VIDEO_REGEX.test(exif.File['MIMEType'])
}
function animated (exif) {
if (exif.File['MIMEType'] !== 'image/gif') return false
if (exif.GIF && exif.GIF.FrameCount > 0) return true
return false
}
function rating (exif) {
if (!exif.XMP) return 0
return exif.XMP['Rating'] || 0
}
function tagValue (exif, type, name) {
if (!exif[type]) return null
return exif[type][name]
}
module.exports = Metadata

@ -2,21 +2,23 @@ const debug = require('debug')('thumbsup')
const path = require('path')
const urljoin = require('url-join')
exports.paths = function (filepath, mediaType, config) {
exports.paths = function (filepath, mediaType, opts) {
if (mediaType === 'image') {
// originals = config ? config.originalPhotos : false
return imageOutput(filepath, config)
const items = imageOutput(filepath)
items.download = download(filepath, opts['downloadPhotos'], opts['downloadLinkPrefix'], items.large)
return items
} else if (mediaType === 'video') {
// originals = config ? config.originalVideos : false
return videoOutput(filepath, config)
const items = videoOutput(filepath)
items.download = download(filepath, opts['downloadVideos'], opts['downloadLinkPrefix'], items.video)
return items
} else {
debug(`Unsupported file type: ${mediaType}`)
return {}
}
}
function imageOutput (filepath, config) {
const output = {
function imageOutput (filepath) {
return {
thumbnail: {
path: 'media/thumbs/' + filepath,
rel: 'photo:thumbnail'
@ -26,12 +28,10 @@ function imageOutput (filepath, config) {
rel: 'photo:large'
}
}
setDownload(filepath, config, 'image', output)
return output
}
function videoOutput (filepath, config) {
var output = {
function videoOutput (filepath) {
return {
thumbnail: {
path: 'media/thumbs/' + ext(filepath, 'jpg'),
rel: 'video:thumbnail'
@ -45,35 +45,28 @@ function videoOutput (filepath, config) {
rel: 'video:resized'
}
}
setDownload(filepath, config, 'video', output)
return output
}
function setDownload (filepath, config, type, output) {
const configKey = (type === 'image' ? 'downloadPhotos' : 'downloadVideos')
const largeVersion = (type === 'image' ? output.large : output.video)
switch (config[configKey]) {
case 'large':
output.download = largeVersion
break
function download (filepath, downloadConfig, linkPrefix, largeVersion) {
switch (downloadConfig) {
case 'copy':
output.download = {
return {
path: path.join('media', 'original', filepath),
rel: 'fs:copy'
}
break
case 'symlink':
output.download = {
return {
path: path.join('media', 'original', filepath),
rel: 'fs:symlink'
}
break
case 'link':
output.download = {
path: join(config.downloadLinkPrefix, filepath),
return {
path: join(linkPrefix, filepath),
rel: 'fs:link'
}
break
case 'large':
default:
return largeVersion
}
}

@ -23,6 +23,7 @@ exports.create = function (options) {
// common partials
handlebars.registerPartial('analytics', compileTemplate(path.join(DIR_TEMPLATES, 'analytics.hbs')))
handlebars.registerPartial('thumbnail', compileTemplate(path.join(DIR_TEMPLATES, 'thumbnail.hbs')))
// theme partials
var files = fs.readdirSync(DIR_THEME)

@ -38,32 +38,7 @@
-->
<ul id="media" class="clearfix">
{{#each album.files~}}
{{#if isVideo~}}
<li data-html="#media{{id}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
</li>
{{~else~}}
<li data-src="{{relative urls.large}}"
data-sub-html="{{caption}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.large}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
{{#if isAnimated}}
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
{{/if}}
</li>
{{~/if}}
{{> thumbnail}}
{{~/each}}
</ul>

@ -33,34 +33,9 @@
</ul>
<ul id="media">
{{#each album.files}}
{{#if isVideo~}}
<li data-html="#media{{id}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
</li>
{{else}}
<li data-src="{{relative urls.large}}"
data-sub-html="{{caption}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
{{#if isAnimated}}
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
{{/if}}
</li>
{{/if}}
{{/each}}
{{#each album.files}}
{{> thumbnail}}
{{/each}}
</ul>
<!--

@ -52,32 +52,7 @@
-->
<ul id="media">
{{#each album.files}}
{{#if isVideo~}}
<li data-html="#media{{id}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
</li>
{{else}}
<li data-src="{{relative urls.large}}"
data-sub-html="{{caption}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
{{#if isAnimated}}
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
{{/if}}
</li>
{{~/if}}
{{> thumbnail}}
{{/each}}
</ul>

@ -0,0 +1,38 @@
{{~#if isVideo~}}
{{!--
Video thumbnails
--}}
<li data-html="#media{{id}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
</li>
{{~else~}}
{{!--
Image thumbnails
--}}
<li data-src="{{relative urls.large}}"
data-sub-html="{{caption}}"
data-download-url="{{relative urls.download}}">
<a href="{{relative urls.download}}">
<img src="{{relative urls.thumbnail}}"
width="{{@root.gallery.thumbSize}}"
height="{{@root.gallery.thumbSize}}"
alt="{{filename}}" />
</a>
{{#if isAnimated}}
<img class="video-overlay" src="{{relative 'public/play.png'}}" />
{{/if}}
</li>
{{~/if~}}

@ -49,6 +49,7 @@
All photos and videos
-->
<ul id="media">
<li data-src="media/large/2016/2016-05-23 13.42.16.jpg"
data-sub-html=""
data-download-url="media/large/2016/2016-05-23 13.42.16.jpg">
@ -58,7 +59,8 @@
height="120"
alt="2016-05-23 13.42.16.jpg" />
</a>
</li> <li data-src="media/large/2016/2016-06-14 09.19.03.jpg"
</li>
<li data-src="media/large/2016/2016-06-14 09.19.03.jpg"
data-sub-html=""
data-download-url="media/large/2016/2016-06-14 09.19.03.jpg">
<a href="media/large/2016/2016-06-14 09.19.03.jpg">
@ -67,7 +69,8 @@
height="120"
alt="2016-06-14 09.19.03.jpg" />
</a>
</li> <li data-src="media/large/2016/2016-10-30 18.41.28.jpg"
</li>
<li data-src="media/large/2016/2016-10-30 18.41.28.jpg"
data-sub-html=""
data-download-url="media/large/2016/2016-10-30 18.41.28.jpg">
<a href="media/large/2016/2016-10-30 18.41.28.jpg">

@ -49,6 +49,7 @@
All photos and videos
-->
<ul id="media">
<li data-src="media/large/2017/2017-01-15 12.38.41.jpg"
data-sub-html=""
data-download-url="media/large/2017/2017-01-15 12.38.41.jpg">
@ -58,7 +59,8 @@
height="120"
alt="2017-01-15 12.38.41.jpg" />
</a>
</li><li data-html="#media5"
</li>
<li data-html="#media5"
data-download-url="media/large/2017/2017-03-22 20.12.58.mp4">
<a href="media/large/2017/2017-03-22 20.12.58.mp4">
<img src="media/thumbs/2017/2017-03-22 20.12.58.jpg"
@ -67,8 +69,7 @@
alt="2017-03-22 20.12.58.mp4" />
</a>
<img class="video-overlay" src="public/play.png" />
</li>
</ul>
</li> </ul>
</section>

@ -1,33 +1,46 @@
const Media = require('../src/model/media')
const moment = require('moment')
const File = require('../src/model/file')
const Metadata = require('../src/model/metadata')
exports.file = function (opts) {
exports.exiftool = function (opts) {
opts = opts || {}
return {
path: opts.path || 'path/image.jpg',
date: opts.date ? new Date(opts.date).getTime() : new Date().getTime(),
type: opts.type || 'image',
meta: {
SourceFile: 'path/image.jpg',
File: {},
EXIF: {},
IPTC: {},
XMP: {}
}
SourceFile: opts.path || 'path/image.jpg',
File: {
FileModifyDate: opts.date || '2016:08:24 14:51:36',
MIMEType: opts.mimeType || 'image/jpg'
},
EXIF: {},
IPTC: {},
XMP: {},
H264: {},
QuickTime: {}
}
}
exports.metadata = function (opts) {
return new Metadata(exports.exiftool(opts))
}
exports.file = function (opts) {
const exiftool = exports.exiftool(opts)
const meta = new Metadata(exiftool)
return new File(exiftool, meta)
}
exports.date = function (str) {
return new Date(Date.parse(str))
return new Date(moment(str, 'YYYYMMDD HHmmss').valueOf())
// return new Date(Date.parse(str))
}
exports.photo = function (opts) {
opts = opts || {}
opts.type = 'image'
return new Media(exports.file(opts))
opts.mimeType = 'image/jpg'
return exports.file(opts)
}
exports.video = function (opts) {
opts = opts || {}
opts.type = 'video'
return new Media(exports.file(opts))
opts.mimeType = 'video/mp4'
return exports.file(opts)
}

@ -1,6 +1,6 @@
const path = require('path')
const should = require('should/as-function')
const hierarchy = require('../../src/model/hierarchy.js')
const hierarchy = require('../../src/input/hierarchy.js')
const Album = require('../../src/model/album.js')
const fixtures = require('../fixtures')
@ -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 = (media) => 'all'
const mapper = (file) => 'all'
const home = hierarchy.createAlbums([], mapper, {})
should(home.title).eql('Home')
})
it('defaults the homepage to index.html', function () {
const mapper = (media) => 'all'
const mapper = (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 = (media) => 'all'
const mapper = (file) => 'all'
const home = hierarchy.createAlbums([], mapper, {index: 'default.html'})
should(home.path).eql('default.html')
should(home.url).eql('default.html')
@ -35,12 +35,12 @@ describe('hierarchy', function () {
const emptyMappers = ['', '.', null]
emptyMappers.forEach(value => {
it(`adds any photos mapped to <${value}> to the root gallery`, function () {
const media = [
const files = [
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = media => value
const home = hierarchy.createAlbums(media, mapper)
const mapper = file => value
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(0)
should(home.files.length).eql(2)
should(home.files[0].filename).eql('IMG_000001.jpg')
@ -51,58 +51,58 @@ describe('hierarchy', function () {
describe('nested albums', function () {
it('can group media into a single folder', function () {
const media = [
const files = [
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = (media) => 'all'
const home = hierarchy.createAlbums(media, mapper)
const mapper = (file) => 'all'
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('all')
should(home.albums[0].files).eql([media[0], media[1]])
should(home.albums[0].files).eql([files[0], files[1]])
})
it('can group media into several folders', function () {
const media = [
const files = [
fixtures.photo({path: 'one/IMG_000001.jpg'}),
fixtures.photo({path: 'two/IMG_000002.jpg'})
]
const mapper = (media) => path.dirname(media.file.path)
const home = hierarchy.createAlbums(media, mapper)
const mapper = (file) => path.dirname(file.path)
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(2)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql([media[0]])
should(home.albums[0].files).eql([files[0]])
should(home.albums[1].title).eql('two')
should(home.albums[1].files).eql([media[1]])
should(home.albums[1].files).eql([files[1]])
})
it('can group media into one nested folder', function () {
const media = [
const files = [
fixtures.photo({path: 'IMG_000001.jpg'}),
fixtures.photo({path: 'IMG_000002.jpg'})
]
const mapper = (media) => 'one/two'
const home = hierarchy.createAlbums(media, mapper)
const mapper = (file) => 'one/two'
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].albums.length).eql(1)
should(home.albums[0].albums[0].title).eql('two')
should(home.albums[0].albums[0].files).eql([media[0], media[1]])
should(home.albums[0].albums[0].files).eql([files[0], files[1]])
})
it('can group media at different levels', function () {
const media = [
const files = [
fixtures.photo({path: 'one/IMG_000001.jpg'}),
fixtures.photo({path: 'one/two/IMG_000002.jpg'})
]
const mapper = (media) => path.dirname(media.file.path)
const home = hierarchy.createAlbums(media, mapper)
const mapper = (file) => path.dirname(file.path)
const home = hierarchy.createAlbums(files, mapper)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql([media[0]])
should(home.albums[0].files).eql([files[0]])
should(home.albums[0].albums.length).eql(1)
should(home.albums[0].albums[0].title).eql('two')
should(home.albums[0].albums[0].files).eql([media[1]])
should(home.albums[0].albums[0].files).eql([files[1]])
})
})
})

@ -1,32 +1,32 @@
var should = require('should/as-function')
var mapper = require('../../src/model/mapper.js')
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 media = fixtures.photo({
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg'
})
should(map(media)).eql('holidays/canada')
should(map(entry)).eql('holidays/canada')
})
it('can create a default date mapper', function () {
const map = mapper.create({albumsFrom: 'date'})
const media = fixtures.photo({
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg',
date: '2016-07-14 12:07:41'
date: '2016:07:14 12:07:41'
})
should(map(media)).eql('2016 July')
should(map(entry)).eql('2016 July')
})
it('can create a custom date mapper', function () {
const map = mapper.create({
albumsFrom: 'date',
albumsDateFormat: 'YYYY/MM'
})
const media = fixtures.photo({
const entry = fixtures.photo({
path: 'holidays/canada/IMG_0001.jpg',
date: '2016-07-14 12:07:41'
date: '2016:07:14 12:07:41'
})
should(map(media)).eql('2016/07')
should(map(entry)).eql('2016/07')
})
})

@ -188,8 +188,8 @@ describe('Album', function () {
]})
var root = new Album({title: 'home', albums: [nested]})
root.finalize({sortMediaBy: 'filename'})
should(nested.files[0].file.path).eql('a')
should(nested.files[1].file.path).eql('b')
should(nested.files[0].path).eql('a')
should(nested.files[1].path).eql('b')
})
})
@ -230,8 +230,8 @@ describe('Album', function () {
]})
var root = new Album({title: 'home', albums: [nested]})
root.finalize({sortMediaBy: 'filename'})
should(nested.files[0].file.path).eql('a')
should(nested.files[1].file.path).eql('b')
should(nested.files[0].path).eql('a')
should(nested.files[1].path).eql('b')
})
})
})

@ -1,7 +1,7 @@
const should = require('should/as-function')
const File = require('../../src/input/file')
const File = require('../../src/model/file')
describe('Input file', function () {
describe('File', function () {
it('reads the relative file path', function () {
var file = new File(dbFile({
SourceFile: 'holidays/beach.jpg'
@ -35,6 +35,22 @@ describe('Input file', function () {
}))
should(file.type).eql('video')
})
it('marks all other data types as unknown', function () {
var file = new File(dbFile({
File: {
MIMEType: 'text/html'
}
}))
should(file.type).eql('unknown')
})
it('has a boolean flag for videos to simplify templates', function () {
var photo = new File(dbFile({File: {MIMEType: 'image/jpeg'}}))
should(photo.isVideo).eql(false)
var video = new File(dbFile({File: {MIMEType: 'video/quicktime'}}))
should(video.isVideo).eql(true)
})
})
function dbFile (data) {

@ -1,137 +0,0 @@
var should = require('should/as-function')
var Media = require('../../src/model/media')
var fixtures = require('../fixtures')
describe('Media', function () {
describe('date taken', function () {
it('reads the EXIF date if present', function () {
const file = fixtures.file()
file.meta.EXIF.DateTimeOriginal = '2016:10:28 17:34:58' // EXIF date format
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('reads the H264 date if present', function () {
const file = fixtures.file()
file.meta.H264 = {}
file.meta.H264.DateTimeOriginal = '2016:10:28 17:34:58' // EXIF date format
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('reads the QuickTime date if present', function () {
const file = fixtures.file()
file.meta.QuickTime = {}
file.meta.QuickTime.CreationDate = '2016:10:28 17:34:58' // EXIF date format
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('infers the date from the filename (Android format)', function () {
const file = fixtures.file({path: 'folder/VID_20170220_114006.mp4'})
const media = new Media(file)
should(media.date).eql(fixtures.date('2017-02-20 11:40:06').getTime())
})
it('infers the date from the filename (Dropbox format)', function () {
const file = fixtures.file({path: 'folder/2017-03-24 19.42.30.jpg'})
const media = new Media(file)
should(media.date).eql(fixtures.date('2017-03-24 19:42:30').getTime())
})
it('only uses the file name, not digits from the folder name', function () {
const file = fixtures.file({path: '1000 photos/2017-03-24 19.42.30.jpg'})
const media = new Media(file)
should(media.date).eql(fixtures.date('2017-03-24 19:42:30').getTime())
})
it('only infers dates from valid formats', function () {
const file = fixtures.file({
path: 'folder/IMG_1234.jpg',
date: '2016-10-28 17:34:58'
})
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('does not look at the file name if it already has EXIF data', function () {
const file = fixtures.file({path: '2017-03-24 19.42.30.jpg'})
file.meta.EXIF.DateTimeOriginal = '2016:10:28 17:34:58'
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('defaults to the file date if there is no other date', function () {
const file = fixtures.file({date: '2016-10-28 17:34:58'})
const media = new Media(file)
should(media.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
})
describe('photos and videos', function () {
it('can tell if a file is a regular photo', function () {
const file = fixtures.file({type: 'image'})
file.meta.File.MIMEType = 'image/jpeg'
const media = new Media(file)
should(media.isVideo).eql(false)
should(media.isAnimated).eql(false)
})
it('can tell if a file is a non-animated gif', function () {
const file = fixtures.file({type: 'image'})
file.meta.File.MIMEType = 'image/gif'
const media = new Media(file)
should(media.isVideo).eql(false)
should(media.isAnimated).eql(false)
})
it('can tell if a file is an animated gif', function () {
const file = fixtures.file({type: 'image'})
file.meta.File.MIMEType = 'image/gif'
file.meta.GIF = {FrameCount: 10}
const media = new Media(file)
should(media.isVideo).eql(false)
should(media.isAnimated).eql(true)
})
it('can tell if a file is a video', function () {
const file = fixtures.file({type: 'video'})
const media = new Media(file)
should(media.isVideo).eql(true)
should(media.isAnimated).eql(false)
})
})
describe('caption', function () {
it('is read from all standard EXIF/IPTC/XMP tags', function () {
const tags = [
{ type: 'EXIF', tag: 'ImageDescription' },
{ type: 'IPTC', tag: 'Caption-Abstract' },
{ type: 'IPTC', tag: 'Headline' },
{ type: 'XMP', tag: 'Description' },
{ type: 'XMP', tag: 'Title' },
{ type: 'XMP', tag: 'Label' }
]
tags.forEach(t => {
const file = fixtures.file()
file.meta[t.type][t.tag] = 'some caption'
const media = new Media(file)
should(media.caption).eql('some caption')
})
})
})
describe('rating', function () {
it('defaults to a rating of 0', function () {
const file = fixtures.file()
const media = new Media(file)
should(media.rating).eql(0)
})
it('reads the rating from the XMP tags', function () {
const file = fixtures.file()
file.meta.XMP['Rating'] = 3
const media = new Media(file)
should(media.rating).eql(3)
})
})
})

@ -0,0 +1,171 @@
const should = require('should/as-function')
const Metadata = require('../../src/model/metadata')
var fixtures = require('../fixtures')
describe('Metadata', function () {
describe('date taken', function () {
it('reads the EXIF date if present', function () {
const exiftool = fixtures.exiftool()
exiftool.EXIF.DateTimeOriginal = '2016:10:28 17:34:58' // EXIF date format
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('reads the H264 date if present', function () {
const exiftool = fixtures.exiftool()
exiftool.H264.DateTimeOriginal = '2016:10:28 17:34:58' // EXIF date format
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('reads the QuickTime date if present', function () {
const exiftool = fixtures.exiftool()
exiftool.QuickTime.CreationDate = '2016:10:28 17:34:58' // EXIF date format
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('infers the date from the filename (Android format)', function () {
const exiftool = fixtures.exiftool()
exiftool.SourceFile = 'folder/VID_20170220_114006.mp4'
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2017-02-20 11:40:06').getTime())
})
it('infers the date from the filename (Dropbox format)', function () {
const exiftool = fixtures.exiftool()
exiftool.SourceFile = 'folder/2017-03-24 19.42.30.jpg'
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2017-03-24 19:42:30').getTime())
})
it('only infers dates from valid formats', function () {
const exiftool = fixtures.exiftool()
exiftool.SourceFile = 'folder/IMG_1234.jpg'
exiftool.File.FileModifyDate = '2016:10:28 17:34:58'
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('does not look at the file name if it already has EXIF data', function () {
const exiftool = fixtures.exiftool()
exiftool.SourceFile = '2017-03-24 19.42.30.jpg'
exiftool.EXIF.DateTimeOriginal = '2016:10:28 17:34:58'
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
it('defaults to the file date if there is no other date', function () {
const exiftool = fixtures.exiftool()
exiftool.File.FileModifyDate = '2016:10:28 17:34:58'
const meta = new Metadata(exiftool)
should(meta.date).eql(fixtures.date('2016-10-28 17:34:58').getTime())
})
})
describe('photos and videos', function () {
it('can tell if a file is a regular photo', function () {
const exiftool = fixtures.exiftool()
exiftool.File.MIMEType = 'image/jpeg'
const meta = new Metadata(exiftool)
should(meta.video).eql(false)
should(meta.animated).eql(false)
})
it('can tell if a file is a non-animated gif', function () {
const exiftool = fixtures.exiftool()
exiftool.File.MIMEType = 'image/gif'
const meta = new Metadata(exiftool)
should(meta.video).eql(false)
should(meta.animated).eql(false)
})
it('can tell if a file is an animated gif', function () {
const exiftool = fixtures.exiftool()
exiftool.File.MIMEType = 'image/gif'
exiftool.GIF = {FrameCount: 10}
const meta = new Metadata(exiftool)
should(meta.video).eql(false)
should(meta.animated).eql(true)
})
it('can tell if a file is a video', function () {
const exiftool = fixtures.exiftool()
exiftool.File.MIMEType = 'video/mp4'
const meta = new Metadata(exiftool)
should(meta.video).eql(true)
should(meta.animated).eql(false)
})
})
describe('caption', function () {
it('is read from all standard EXIF/IPTC/XMP tags', function () {
const tags = [
{ type: 'EXIF', tag: 'ImageDescription' },
{ type: 'IPTC', tag: 'Caption-Abstract' },
{ type: 'IPTC', tag: 'Headline' },
{ type: 'XMP', tag: 'Description' },
{ type: 'XMP', tag: 'Title' },
{ type: 'XMP', tag: 'Label' }
]
tags.forEach(t => {
const exiftool = fixtures.exiftool()
exiftool[t.type][t.tag] = 'some caption'
const meta = new Metadata(exiftool)
should(meta.caption).eql('some caption')
})
})
})
describe('keywords', function () {
it('defaults to an empty array', function () {
const exiftool = fixtures.exiftool()
const meta = new Metadata(exiftool)
should(meta.keywords).eql([])
})
it('can read IPTC keywords', function () {
const exiftool = fixtures.exiftool()
exiftool.IPTC['Keywords'] = 'beach,sunset'
const meta = new Metadata(exiftool)
should(meta.keywords).eql(['beach', 'sunset'])
})
xit('can read Picasa keywords', function () {
const exiftool = fixtures.exiftool()
const picasa = {keywords: 'beach,sunset'}
const meta = new Metadata(exiftool, picasa)
should(meta.keywords).eql(['beach', 'sunset'])
})
})
describe('rating', function () {
it('defaults to a rating of 0', function () {
const exiftool = fixtures.exiftool()
const meta = new Metadata(exiftool)
should(meta.rating).eql(0)
})
it('reads the rating from the XMP tags', function () {
const exiftool = fixtures.exiftool()
exiftool.XMP['Rating'] = 3
const meta = new Metadata(exiftool)
should(meta.rating).eql(3)
})
})
xdescribe('favourite', function () {
it('defaults to false', function () {
const exiftool = fixtures.exiftool()
const meta = new Metadata(exiftool)
should(meta.favourite).eql(false)
})
it('understands the Picasa <star> feature', function () {
const exiftool = fixtures.exiftool()
const picasa = {star: 'yes'}
const meta = new Metadata(exiftool, picasa)
should(meta.favourite).eql(true)
})
})
})

@ -1,5 +1,5 @@
const should = require('should/as-function')
const output = require('../../src/input/output')
const output = require('../../src/model/output')
describe('Output paths', function () {
describe('Images', function () {
Loading…
Cancel
Save