UNPKG

@mattduffy/exiftool

Version:

A simple object oriented wrapper for the exiftool image metadata utility.

1,373 lines (1,330 loc) 54.5 kB
/** * @module @mattduffy/exiftool * @author Matthew Duffy <mattduffy@gmail.com> * @summary The Exiftool class definition file. * @file src/index.js */ import path from 'node:path' import { fileURLToPath } from 'node:url' import { stat } from 'node:fs/promises' import { promisify } from 'node:util' import { exec, } from 'node:child_process' import Debug from 'debug' import * as fxp from 'fast-xml-parser' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const cmd = promisify(exec) Debug.log = console.log.bind(console) const debug = Debug('exiftool') const error = debug.extend('ERROR') /** * A class wrapping the exiftool metadata tool. * @summary A class wrapping the exiftool image metadata extraction tool. * @class Exiftool * @author Matthew Duffy <mattduffy@gmail.com> */ export class Exiftool { /** * Create an instance of the exiftool wrapper. * @param { string } imagePath - String value of file path to an image file or directory * of images. * @param { Boolean } [test] - Set to true to test outcome of exiftool command not found. */ constructor(imagePath, test) { const log = debug.extend('constructor') log('constructor method entered') this._test = test ?? false this._imgDir = imagePath ?? null this._path = imagePath ?? null this._isDirectory = null this._fileStats = null this._cwd = __dirname this._exiftool_config = `"${this._cwd}/exiftool.config"` this._extensionsToExclude = ['txt', 'js', 'json', 'mjs', 'cjs', 'md', 'html', 'css'] this._executable = null this._version = null this._MAX_BUFFER_MULTIPLIER = 10 this._opts = {} this._opts.exiftool_config = `-config ${this._exiftool_config}` this._opts.outputFormat = '-json' this._opts.tagList = null this._opts.shortcut = '-BasicShortcut' this._opts.includeTagFamily = '-groupNames' this._opts.compactFormat = '-s3' this._opts.quiet = '-quiet' this._opts.excludeTypes = '' this._opts.binaryFormat = '' this._opts.gpsFormat = '' this._opts.structFormat = '' this._opts.useMWG = '' this._opts.overwrite_original = '' this._command = null this.orderExcludeTypesArray() } /** * Initializes some asynchronus properties. * @summary Initializes some asynchronus class properties not done in the constructor. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } imagePath - A file system path to set for exiftool to process. * @return { (Exiftool|Boolean) } Returns fully initialized instance or false. */ async init(imagePath) { const log = debug.extend('init') const err = error.extend('init') log('init method entered') try { if (this._executable === null) { this._executable = await this.which() this._version = await this.version() } log('setting the command string') this.setCommand() } catch (e) { err('could not find exiftool command') // err(e) throw new Error( 'ATTENTION!!! ' + 'exiftool IS NOT INSTALLED. ' + 'You can get exiftool at https://exiftool.org/install.html', { cause: e }, ) } if ((imagePath === '' || typeof imagePath === 'undefined') && this._path === null) { err('Param: path - was undefined.') err(`Instance property: path - ${this._path}`) return false } try { await this.setPath(imagePath) } catch (e) { err(e) throw e } try { log('checking if config file exists.') if (await this.hasExiftoolConfigFile()) { log('exiftool.config file exists') } else { log('missing exiftool.config file') log('attempting to create basic exiftool.config file') const result = this.createExiftoolConfigFile() if (!result.value && result.error) { err('failed to create new exiftool.config file') throw new Error(result.error) } log('new exiftool.config file created') } } catch (e) { err('could not create exiftool.config file') err(e) } return this } /** * Set the maxBuffer size for stdio to support larger image files. * @summary Set the maxBuffer size for stdio to support larger image files. * @author Matthew Duffy <mattduffy@gmail.com> * @param { Number } multiplier - Value to multiply the default (1024x1024) setting. * @return { undefined } */ setMaxBufferMultiplier(multiplier) { const log = debug.extend('setMaxBufferMultiplier') const _multiplier = Number.parseInt(multiplier, 10) if (_multiplier) { const orig = (1024 * 1024) * this._MAX_BUFFER_MULTIPLIER this._MAX_BUFFER_MULTIPLIER = _multiplier const now = (1024 * 1024) * this._MAX_BUFFER_MULTIPLIER log(`setting stdio maxBuffer ${orig} to ${now}`) } } getOutputBufferSize() { return `${this._MAX_BUFFER_MULTIPLIER * (1024 * 1024)} Bytes` } /** * Set ExifTool to overwrite the original image file when writing new tag data. * @summary Set ExifTool to overwrite the original image file when writing new tag data. * @author Matthew Duffy <mattduffy@gmail.com> * @param { Boolean } enabled - True/False value to enable/disable overwriting the original * image file. * @return { undefined } */ setOverwriteOriginal(enabled) { const log = debug.extend('setOverwriteOriginal') if (enabled) { log('setting -overwrite_original option') this._opts.overwrite_original = '-overwrite_original' } else { this._opts.overwrite_original = '' } } /** * Set ExifTool to extract binary tag data. * @summary Set ExifTool to extract binary tag data. * @author Matthew Duffy <mattduffy@gmail.com> * @param { Boolean } enabled - True/False value to enable/disable binary tag extraction. * @return { undefined } */ enableBinaryTagOutput(enabled) { const log = debug.extend('enableBinaryTagOutput') if (enabled) { log('Enabling binary output.') this._opts.binaryFormat = '-binary' } else { log('Disabling binary output.') this._opts.binaryFormat = '' } this.setCommand() } /** * Set ExifTool output format. * @summary Set Exiftool output format. * @author Matthew Duffy <mattduffy@gmail.com> * @param { String } [fmt='json'] - Output format to set, default is JSON, but can be XML. * @return { Boolean } - Return True if new format is set, False otherwise. */ setOutputFormat(fmt = 'json') { const log = debug.extend('setOutputFormat') const err = error.extend('setOutputFormat') let newFormat const match = fmt.match(/(?<format>xml|json)/i) if (match || match.groups?.format) { newFormat = (match.groups.format === 'xml') ? '-xmlFormat' : '-json' this._opts.outputFormat = newFormat log(`Output format is set to ${this._opts.outputFormat}`) this.setCommand() return true } err(`Output format ${fmt} not supported.`) return false } /** * Set ExifTool output formatting for GPS coordinate data. * @summary Set ExifTool output formatting for GPS coordinate data. * @author Matthew Duffy <mattduffy@gmail.com> * @param { String } [fmt=default] - Printf format string with specifiers for degrees, * minutes and seconds. * @see {@link https://exiftool.org/exiftool_pod.html#c-FMT--coordFormat} * @return { undefined } */ setGPSCoordinatesOutputFormat(fmt = 'default') { const log = debug.extend('setGPSCoordinatesOutputFormat') const groups = fmt.match(/(?<signed>\+)?(?<gps>gps)/i)?.groups if (fmt.toLowerCase() === 'default') { // revert to default formatting this._opts.coordFormat = '' } else if (groups?.gps === 'gps') { this._opts.coordFormat = `-coordFormat %${(groups?.signed ? '+' : '')}.6f` } else { this._opts.coordFormat = `-coordFormat ${fmt}` } log(`GPS format is now ${fmt}`) } /** * Set ExifTool to extract xmp struct tag data. * @summary Set ExifTool to extract xmp struct tag data. * @author Matthew Duffy <mattduffy@gmail.com> * @param { Boolean } enabled - True/False value to enable/disable xmp struct tag extraction. * @return { undefined } */ enableXMPStructTagOutput(enabled) { const log = debug.extend('enableXMPStructTagOutput') if (enabled) { log('Enabling XMP struct output format.') this._opts.structFormat = '-struct' } else { log('Disabling XMP struct output format.') this._opts.structFormat = '' } } /** * Tell exiftool to use the Metadata Working Group (MWG) module for overlapping EXIF, IPTC, * and XMP tqgs. * @summary Tell exiftool to use the MWG module for overlapping tag groups. * @author Matthew Duffy <mattduffy@gmail.com> * @param { Boolean } - True/false value to enable/disable mwg module. * @return { undefined } */ useMWG(enabled) { const log = debug.extend('useMWG') if (enabled) { log('Enabling MWG.') this._opts.useMWG = '-use MWG' } else { log('Disabling MWG.') this._opts.useMGW = '' } } /** * Set the path for image file or directory of images to process with exiftool. * @summary Set the path of image file or directory of images to process with exiftool. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } imagePath - A file system path to set for exiftool to process. * @return { Object } Returns an object literal with success or error messages. */ async setPath(imagePath) { const log = debug.extend('setPath') const err = error.extend('setPath') log('setPath method entered') const o = { value: null, error: null } if (typeof imagePath === 'undefined' || imagePath === null) { o.error = 'A path to image or directory is required.' err(o.error) return o } let pathToImage if (Array.isArray(imagePath)) { let temp = imagePath.map((i) => `"${path.resolve('.', i)}"`) temp = temp.join(' ') log( 'imagePath passed as an Array. Resolving and concatting the paths into a single ' + `string: ${temp}`, ) pathToImage = temp } else { pathToImage = `"${path.resolve('.', imagePath)}"` } if (!/^(")?\//.test(pathToImage)) { // the path parameter must be a fully qualified file path, starting with / throw new Error( 'The file system path to image must be a fully qualified path, starting from root /.', ) } try { this._path = pathToImage if (/^"/.test(pathToImage)) { this._fileStats = await stat(pathToImage.slice(1, -1)) } else { this._fileStats = await stat(pathToImage) } this._isDirectory = this._fileStats.isDirectory() if (this._fileStats.isDirectory()) { this._imgDir = pathToImage } this.setCommand() o.value = true } catch (e) { err(e) o.error = e.message o.errorCode = e.code o.errorStack = e.stack } return o } /** * Get the fully qualified path to the image (or directory) specified in init. * @summary Get the full qualified path to the image. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @return { Object } Returns an object literal with success or error messages. */ async getPath() { const log = debug.extend('getPath') const err = error.extend('getPath') log('getPath method entered') const o = { value: null, error: null } if (this._path === null || typeof this._path === 'undefined' || this._path === '') { o.error = 'Path to an image file or image directory is not set.' err(o.error) } else { o.value = true o.file = (this._isDirectory) ? null : path.basename(this._path) o.dir = (this._isDirectory) ? this._path : path.dirname(this._path) o.path = this._path } return o } /** * Check to see if the exiftool.config file is present at the expected path. * @summary Check to see if the exiftool.config file is present at the expected path. * @author Matthew Duffy <mattduffy@gmail.com> * @return { Boolean } Returns True if present, False if not. */ async hasExiftoolConfigFile() { const log = debug.extend('hasExiftoolConfigFile') const err = error.extend('hasExiftoolConfigFile') log('hasExiftoolConfigFile method entered') log('>') let exists = false const file = this._exiftool_config let stats try { log('>>') if (/^"/.test(file)) { stats = await stat(file.slice(1, -1)) } else { stats = await stat(file) } log('>>>') log(stats) exists = true } catch (e) { err('>>>>') err(e) exists = false } log('>>>>>') return exists } /** * Create the exiftool.config file if it is not present. * @summary Create the exiftool.config file if it is not present. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @return { Object } Returns an object literal with success or error messages. */ async createExiftoolConfigFile() { const log = debug.extend('createExiftoolConfigFile') const err = error.extend('createExiftoolConfigFile') log('createExiftoolConfigFile method entered') const o = { value: null, error: null } const stub = `%Image::ExifTool::UserDefined::Shortcuts = ( BasicShortcut => ['file:Directory','file:FileName','EXIF:CreateDate','file:MIMEType','exif:Make','exif:Model','exif:ImageDescription','iptc:ObjectName','iptc:Caption-Abstract','iptc:Keywords','Composite:GPSPosition'], Location => ['EXIF:GPSLatitudeRef', 'EXIF:GPSLatitude', 'EXIF:GPSLongitudeRef', 'EXIF:GPSLongitude', 'EXIF:GPSAltitudeRef', 'EXIF:GPSSpeedRef', 'EXIF:GPSAltitude', 'EXIF:GPSSpeed', 'EXIF:GPSImgDirectionRef', 'EXIF:GPSImgDirection', 'EXIF:GPSDestBearingRef', 'EXIF:GPSDestBearing', 'EXIF:GPSHPositioningError', 'Composite:GPSAltitude', 'Composite:GPSLatitude', 'Composite:GPSLongitude', 'Composite:GPSPosition', 'XMP:Location*', 'XMP:LocationCreatedGPSLatitude', 'XMP:LocationCreatedGPSLongitude', 'XMP:LocationShownGPSLatitude', 'XMP:LocationShownGPSLongitude'], StripGPS => ['gps:all='], );` // let fileName = `${this._cwd}/exiftool.config` const fileName = this._exiftool_config const echo = `echo "${stub}" > ${fileName}` try { log('attemtping to create exiftool.config file') const result = await cmd(echo) log(result.stdout) o.value = true } catch (e) { err('failed to create new exiftool.config file') err(e) o.error = e.message o.errorCode = e.code o.errorStack = e.stack } return o } /** * Set the GPS location to point to a new point. * @summary Set the GPS location to point to a new point. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { Object } coordinates - New GPS coordinates to assign to image. * @param { Number } coordinates.latitude - Latitude component of location. * @param { Number } coordinates.longitude - Longitude component of location. * @param { String } [coordinates.city] - City name to be assigned using MWG composite method. * @param { String } [coordinates.state] - State name to be assigned using MWG composite * method. * @param { String } [coordindates.country] - Country name to be assigned using MWG composite * method. * @param { String } [coordindates.countryCode] - Country code to be assigned using MWG * composite method. * @param { String } [coordinates.location] - Location name to be assigned using MWG composite * method. * @throws { Error } Throws an error if no image is set yet. * @return { Object } Object literal with stdout or stderr. */ async setLocation(coordinates) { const log = debug.extend('setLocation') const err = error.extend('setLocation') if (!this._path) { throw new Error('No image file set yet.') } try { const lat = parseFloat(coordinates?.latitude) ?? null const latRef = `${(lat > 0) ? 'N' : 'S'}` const lon = parseFloat(coordinates?.longitude) ?? null const lonRef = `${(lon > 0) ? 'E' : 'W'}` const alt = 10000 const altRef = 0 let command = `${this._executable} ` if (lat && lon) { command += `-GPSLatitude=${lat} -GPSLatitudeRef=${latRef} -GPSLongitude=${lon} ` + `-GPSLongitudeRef=${lonRef} -GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ` + `-XMP:LocationShownGPSLatitude=${lat} -XMP:LocationShownGPSLongitude=${lon}` } if (coordinates?.city !== undefined) { command += ` -IPTC:City='${coordinates.city}' ` + `-XMP-iptcExt:LocationShownCity='${coordinates.city}' ` + `-XMP:City='${coordinates.city}'` // command += ` -MWG:City='${coordinates.city}'` } if (coordinates?.state !== undefined) { command += ` -IPTC:Province-State='${coordinates.state}' ` + `-XMP-iptcExt:LocationShownProvinceState='${coordinates.state}' ` + `-XMP:Country='${coordinates.state}'` // command += ` -MWG:State='${coordinates.state}'` } if (coordinates?.country !== undefined) { command += ` -IPTC:Country-PrimaryLocationName='${coordinates.country}' ` + '-XMP:LocationShownCountryName= ' + `-XMP:LocationShownCountryName='${coordinates.country}' ` + `-XMP:Country='${coordinates.country}'` // command += ` -MWG:Country='${coordinates.country}'` } if (coordinates?.countryCode !== undefined) { command += ` -IPTC:Country-PrimaryLocationCode='${coordinates.countryCode}' ` + '-XMP:LocationShownCountryCode= ' + `-XMP:LocationShownCountryCode='${coordinates.countryCode}' ` + `-XMP:CountryCode='${coordinates.countryCode}'` // command += ` -MWG:Country='${coordinates.country}'` } if (coordinates?.location !== undefined) { command += ` -IPTC:Sub-location='${coordinates.location}' ` + `-XMP-iptcExt:LocationShownSublocation='${coordinates.location}' ` + `-XMP:Location='${coordinates.location}'` // command += ` -MWG:Location='${coordinates.location}'` } command += ` -struct -codedcharacterset=utf8 ${this._path}` log(command) const result = await cmd(command) result.exiftool_command = command log('set new location: %o', result) return result } catch (e) { err(e) throw new Error(e) } } /** * Set the GPS location to point to null island. * @summary Set the GPS location to point to null island. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throws an error if no image is set yet. * @return { Object } Object literal with stdout or stderr. */ async nullIsland() { const log = debug.extend('nullIsland') const err = error.extend('nullIsland') if (!this._path) { throw new Error('No image file set yet.') } try { const latitude = 0.0 const latRef = 'S' const longitude = 0.0 const longRef = 'W' const alt = 10000 const altRef = 0 const command = `${this._executable} -GPSLatitude=${latitude} ` + `-GPSLatitudeRef=${latRef} -GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} ` + `-GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ${this._path}` const result = await cmd(command) result.exiftool_command = command log('null island: %o', result) return result } catch (e) { err(e) throw new Error(e) } } /** * Set the GPS location to point nemo. * @summary Set the GPS location to point nemo. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throws an error if no image is set yet. * @return { Object } Object literal with stdout or stderr. */ async nemo() { const log = debug.extend('nemo') const err = error.extend('nemo') if (!this._path) { throw new Error('No image file set yet.') } try { const latitude = 22.319469 const latRef = 'S' const longitude = 114.189505 const longRef = 'W' const alt = 10000 const altRef = 0 const command = `${this._executable} -GPSLatitude=${latitude} -GPSLatitudeRef=${latRef} ` + `-GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} -GPSAltitude=${alt} ` + `-GPSAltitudeRef=${altRef} ${this._path}` const result = await cmd(command) result.exiftool_command = command log('nemo: %o', result) return result } catch (e) { err(e) throw new Error(e) } } /** * Strip all location data from the image. * @summary Strip all location data from the image. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throws an error if no image is set yet. * @return { Object } Object literal with stdout or stderr. */ async stripLocation() { const log = debug.extend('stripLocation') const err = error.extend('stripLocation') if (!this._path) { const msg = 'No image file set yet.' err(msg) throw new Error(msg) } try { const tags = `${this._opts.overwrite_original} -gps:all= -XMP:LocationShown*= ` + '-XMP:LocationCreated*= -XMP:Location= -XMP:City= -XMP:Country*= -IPTC:City= ' + '-IPTC:Province-State= -IPTC:Sub-location= -IPTC:Country*= ' const command = `${this._executable} ${tags} ${this._path}` const result = await cmd(command) result.exiftool_command = command log('stripLocation: %o', result) return result } catch (e) { err(e) throw new Error(e) } } /** * Find the path to the executable exiftool binary. * @summary Find the path to the executable exiftool binary. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @return { String|Error } Returns the file system path to exiftool binary, or throws an * error. */ async which() { const log = debug.extend('which') const err = error.extend('which') if (this._executable !== null) { return this._executable } let which try { // test command not founc condition const exiftool = (!this?._test) ? 'exiftool' : 'exitfool' // which = await cmd('which exiftool') which = await cmd(`which ${exiftool}`) if (which.stdout.slice(-1) === '\n') { which = which.stdout.slice(0, -1) this._executable = which log(`found: ${which}`) } } catch (e) { err(e) throw new Error('Exiftool not found?', { cause: e }) } return which } /** Get the version number of the currently installed exiftool. * @summary Get the version number of the currently installed exiftool. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @returns { String|Error } Returns the version of exiftool as a string, or throws an error. */ async version() { const log = debug.extend('version') const err = error.extend('version') if (this._version !== null) { return this._version } let ver const _exiftool = (this._executable !== null ? this._executable : await this.which()) try { ver = await cmd(`${_exiftool} -ver`) if (ver.stdout.slice(-1) === '\n') { ver = ver.stdout.slice(0, -1) this._version = ver log(`found: ${ver}`) } } catch (e) { err(e) throw new Error('Exiftool not found?', { cause: e }) } return ver } /** * Set the full command string from the options. * @summary Set the full command string from the options. * @author Matthew Duffy <mattduffy@gmail.com> * @return { undefined } */ setCommand() { const log = debug.extend('setCommand') this._command = `${this._executable} ${this.getOptions()} ${this._path}` log(`exif command set: ${this._command}`) } /** * Lexically order the array of file extensions to be excluded from the exiftool query. * @summary Lexically order the array of file extensions to be excluded from the exiftool * query. * @author Matthew Duffy <mattduffy@gmail.com> * @return { undefined } */ orderExcludeTypesArray() { const log = debug.extend('orderExcludeTypesArray') this._extensionsToExclude.forEach((ext) => ext.toLowerCase()) this._extensionsToExclude.sort((a, b) => { if (a.toLowerCase() < b.toLowerCase()) return -1 if (a.toLowerCase() > b.toLowerCase()) return 1 return 0 }) log(this._extensionsToExclude) // this._extensionsToExclude = temp } /** * Compose the command line string of file type extentions for exiftool to exclude. * @summary Compose the command line string of file type extensions for exiftool to exclude. * @author Matthew Duffy <mattduffy@gmail.com> * @return { undefined } */ setExcludeTypes() { const log = debug.extend('setExcludeTypes') this._extensionsToExclude.forEach((ext) => { this._opts.excludeTypes += `--ext ${ext} ` }) log(this._extensionsToExclude) } /** * Get the instance property array of file type extentions for exiftool to exclude. * @summary Get the instance property array of file type extensions for exiftool to exclude. * @author Matthew Duffy <mattduffy@gmail.com> * @returns { String[] } The array of file type extentions for exiftool to exclude. */ getExtensionsToExclude() { return this._extensionsToExclude } /** * Set the array of file type extentions that exiftool should ignore while recursing through * a directory. * @summary Set the array of file type extenstions that exiftool should ignore while * recursing through a directory. * @author Matthew Duffy <mattduffy@gmail.com> * @throws Will throw an error if extensionsArray is not an Array. * @param { String[] } extensionsToAddArray - An array of file type extensions to add to the * exclude list. * @param { String[] } extensionsToRemoveArray - An array of file type extensions to remove * from the exclude list. * @return { undefined } */ setExtensionsToExclude(extensionsToAddArray = null, extensionsToRemoveArray = null) { const log = debug.extend('setExtensiosToExclude') // if (extensionsToAddArray !== '' || extensionsToAddArray !== null) { if (extensionsToAddArray !== null) { if (extensionsToAddArray.constructor !== Array) { throw new Error('Expecting an array of file extensions to be added.') } extensionsToAddArray.forEach((ext) => { if (!this._extensionsToExclude.includes(ext.toLowerCase())) { this._extensionsToExclude.push(ext.toLowerCase()) } }) } // if (extensionsToRemoveArray !== '' || extensionsToRemoveArray !== null) { if (extensionsToRemoveArray !== null) { if (extensionsToRemoveArray.constructor !== Array) { throw new Error('Expecting an array of file extensions to be removed.') } extensionsToRemoveArray.forEach((ext) => { const index = this._extensionsToExclude.indexOf(ext.toLowerCase()) if (index > 0) { this._extensionsToExclude.splice(index, 1) } }) } this.orderExcludeTypesArray() this._opts.excludeTypes = '' this.setExcludeTypes() log(this._opts.excludeTypes) } /** * Concatenate all the exiftool options together into a single string. * @summary Concatenate all the exiftool options together into a single string. * @author Matthew Duffy <mattduffy@gmail.com> * @return { String } Commandline options to exiftool. */ getOptions() { const log = debug.extend('getOptions') let tmp = '' if (this._opts.excludeTypes === '') { this.setExcludeTypes() } // return Object.values(this._opts).join(' ') Object.keys(this._opts).forEach((key) => { // log(`checking _opts keys: _opts[${key}]: ${this._opts[key]}`) if (/overwrite_original/i.test(key)) { log(`ignoring ${key}`) log('well, not really for now.') // tmp += '' tmp += `${this._opts[key]} ` } else if (/tagList/i.test(key) && this._opts.tagList === null) { // log(`ignoring ${key}`) tmp += '' } else { tmp += `${this._opts[key]} ` } }) log('option string: ', tmp) return tmp } /** * Set the file system path to a different exiftool.config to be used. * @summary Set the file system path to a different exiftool.config to be used. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } newConfigPath - A string containing the file system path to a valid * exiftool.config file. * @return { Object } Returns an object literal with success or error messages. */ async setConfigPath(newConfigPath) { const log = debug.extend('setConfigPath') const o = { value: null, error: null } if (newConfigPath === '' || newConfigPath === null) { o.error = 'A valid file system path to an exiftool.config file is required.' } else { try { // const stats = await stat(newConfigPath) if (/^"/.test(newConfigPath)) { await stat(newConfigPath.slice(1, -1)) this._exiftool_config = newConfigPath } else { await stat(newConfigPath) this._exiftool_config = `"${newConfigPath}"` } o.value = true this._opts.exiftool_config = `-config ${this._exiftool_config}` this.setCommand() } catch (e) { o.value = false o.error = e.message o.e = e } } log(`Config path set to: ${this._exiftool_config}`) return o } /** * Get the instance property for the file system path to the exiftool.config file. * @summary Get the instance property for the file system path to the exiftool.config file. * @author Matthew Duffy <mattduffy@gmail.com> * @returns { Object } Returns an object literal with success or error messages. */ getConfigPath() { const log = debug.extend('getConfigPath') log('getConfigPath method entered') const o = { value: null, error: null } if (this._exiftool_config === '' || this._exiftool_config === null || typeof this._exiftool_config === 'undefined') { o.error = 'No path set for the exiftool.config file.' } else if (/^"/.test(this._exiftool_config)) { o.value = this._exiftool_config.slice(1, -1) } else { o.value = this._exiftool_config } return o } /** * Check the exiftool.config to see if the specified shortcut exists. * @summary Check to see if a shortcut exists. * @author Matthew Duffy <mattduffy@gmail.com> * @param { String } shortcut - The name of a shortcut to check if it exists in the * exiftool.config. * @return { Boolean } Returns true if the shortcut exists in the exiftool.config, false if * not. */ async hasShortcut(shortcut) { const log = debug.extend('hasShortcut') const err = error.extend('hasShortcut') let exists if (shortcut === 'undefined' || shortcut === null) { exists = false } else { try { const re = new RegExp(`${shortcut}`, 'i') const grep = `grep -i "${shortcut}" ${this._exiftool_config}` const output = await cmd(grep) output.grep_command = grep log('grep -i: %o', output) const stdout = output.stdout?.match(re) if (shortcut.toLowerCase() === stdout[0].toLowerCase()) { exists = true } else { exists = false } } catch (e) { err(e) exists = false } } return exists } /** * Add a new shortcut to the exiftool.config file. * @summary Add a new shortcut to the exiftool.config file. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } newShortcut - The string of text representing the new shortcut to add * to exiftool.config file. * @return { Object } Returns an object literal with success or error messages. */ async addShortcut(newShortcut) { const log = debug.extend('addShortcut') const err = error.extend('addShortcut') const o = { value: null, error: null } if (newShortcut === 'undefined' || newShortcut === '') { o.error = 'Shortcut name must be provided as a string.' } else { try { let sedCommand if (process.platform === 'darwin') { /* eslint-disable-next-line no-useless-escape */ sedCommand = `sed -i'.bk' -e '2i\\ ${newShortcut},' ${this._exiftool_config}` } else { sedCommand = `sed -i.bk "2i\\ ${newShortcut}," ${this._exiftool_config}` } log(`sed command: ${sedCommand}`) const output = await cmd(sedCommand) log(output) o.command = sedCommand if (output.stderr === '') { o.value = true } else { o.value = false o.error = output.stderr } } catch (e) { err(`Failed to add shortcut, ${newShortcut}, to exiftool.config file`) err(e) } } return o } /** * Remove a shorcut from the exiftool.config file. * @summary Remove a shortcut from the exiftool.config file. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } shortcut - A string containing the name of the shortcut to remove. * @return { Object } Returns an object literal with success or error messages. */ async removeShortcut(shortcut) { const log = debug.extend('removeShortcut') const err = error.extend('removeShortcut') const o = { value: null, error: null } if (shortcut === 'undefined' || shortcut === '') { o.error = 'Shortcut name must be provided as a string.' } else { try { const sedCommand = `sed -i.bk "/${shortcut}/d" ${this._exiftool_config}` o.command = sedCommand log(`sed command: ${sedCommand}`) const output = await cmd(sedCommand) log(output) if (output.stderr === '') { o.value = true } else { o.value = false o.error = output.stderr } } catch (e) { err(`Failed to remove shortcut, ${shortcut}, from the exiftool.config file.`) err(e) } } return o } /** * Clear the currently set exiftool shortcut. No shortcut means exiftool returns all tags. * @summary Clear the currently set exiftool shortcut. * @author Matthew Duffy <mattduffy@gmail.com> * @return { undefined } */ clearShortcut() { const log = debug.extend('clearShortcut') this._opts.shortcut = '' this.setCommand() log('Shortcut option cleared.') } /** * Set a specific exiftool shortcut. The new shortcut must already exist in the * exiftool.config file. * @summary Set a specific exiftool shortcut to use. * @author Matthew Duffy <mattduffy@gmail.com> * @param { String } shortcut - The name of a new exiftool shortcut to use. * @return { Object } Returns an object literal with success or error messages. */ setShortcut(shortcut) { const log = debug.extend('setShortcut') const err = error.extend('setShortcut') const o = { value: null, error: null } if (shortcut === undefined || shortcut === null) { o.error = 'Shortcut must be a string value.' err(o.error) } else { this._opts.shortcut = `-${shortcut}` this.setCommand() o.value = true log(`Shortcut set to: ${this._opts.shortcut}`) } return o } /** * Set one or more explicit metadata tags in the command string for exiftool to extract. * @summary Set one or more explicit metadata tags in the command string for exiftool to * extract. * @author Matthew Duffy <mattduffy@gmail.com> * @param { String|String[]} tagsToExtract - A string or an array of metadata tags to be * passed to exiftool. * @return { Object } Returns an object literal with success or error messages. */ setMetadataTags(tagsToExtract) { const log = debug.extend('setMetadataTags') const err = error.extend('setMetadataTags') let tags log(`>> ${tagsToExtract}`) const o = { value: null, error: null } if (tagsToExtract === 'undefined' || tagsToExtract === '' || tagsToExtract === null) { o.error = 'One or more metadata tags are required' err(o.error) } else { if (Array === tagsToExtract.constructor) { log('array of tags') // check array elements so they all have '-' prefix tags = tagsToExtract.map((tag) => { if (!/^-{1,1}[^-]?.+$/.test(tag)) { return `-${tag}` } return tag }) log(tags) // join array elements in to a string this._opts.tagList = `${tags.join(' ')}` } if (String === tagsToExtract.constructor) { log('string of tags') if (tagsToExtract.match(/^-/) === null) { this._opts.tagList = `-${tagsToExtract}` } this._opts.tagList = tagsToExtract } log(this._opts.tagList) log(this._command) this.setCommand() o.value = true } return o } /** * Run the composed exiftool command to get the requested exif metadata. * @summary Get the exif metadata for one or more image files. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throw an error if -all= tag is included in the tagsToExtract parameter. * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. * @param { String } [ fileOrDir=null ] - The string path to a file or directory for * exiftool to use. * @param { String } [ shortcut=''] - A string containing the name of an existing shortcut * for exiftool to use. * @param { String } [ tagsToExtract=null ] - A string of one or more metadata tags to pass * to exiftool. * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. */ async getMetadata(fileOrDir = null, shortcut = '', ...tagsToExtract) { const log = debug.extend('getMetadata') const err = error.extend('getMetadata') if (fileOrDir !== null && fileOrDir !== '') { await this.setPath(fileOrDir) } log(`shortcut: ${shortcut}`) // if (shortcut !== null && shortcut !== '') { if (shortcut !== null && shortcut !== '' && shortcut !== false) { this.setShortcut(shortcut) } else if (shortcut === null || shortcut === false) { this.clearShortcut() } else { // leave default BasicShortcut in place // this.clearShortcut() log(`leaving any currenly set shortcut in place: ${this._opts.shortcut}`) } if (tagsToExtract.length > 0) { if (tagsToExtract.includes('-all= ')) { err("Can't include metadata stripping -all= tag in get metadata request.") throw new Error("Can't include metadata stripping -all= tag in get metadata reqeust.") } const options = this.setMetadataTags(tagsToExtract.flat()) log(options) log(this._opts) if (options.error) { err(options.error) throw new Error('tag list option failed') } } log(this._command) try { let count // Increase the stdio buffer size because some images have almost as much // metadata stuffed insided as image data itself. This sets stdio output // buffer sizee to 10MB. let metadata = await cmd(this._command, { maxBuffer: (1024 * 1204) * this._MAX_BUFFER_MULTIPLIER, }) if (metadata.stderr !== '') { throw new Error(metadata.stderr) } const match = this._opts.outputFormat.match(/(?<format>xml.*|json)/i) if (match && match.groups.format === 'json') { metadata = JSON.parse(metadata.stdout) count = metadata.length metadata.push({ exiftool_command: this._command }) metadata.push({ format: 'json' }) metadata.push(count) } else if (match && match.groups.format === 'xmlFormat') { const tmp = [] const parser = new fxp.XMLParser() const xml = parser.parse(metadata.stdout) log(xml) tmp.push(xml) tmp.push({ raw: metadata.stdout }) tmp.push({ format: 'xml' }) tmp.push({ exiftool_command: this._command }) tmp.push(count) metadata = tmp } else { metadata = metadata.stdout } log(metadata) return metadata } catch (e) { err(e) e.exiftool_command = this._command return e } } async getThumbnail(image) { return this.getThumbnails(image) } /** * Extract any embedded thumbnail/preview images. * @summary Extract any embedded thumbnail/preview images. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } [image] - The name of the image to get thumbnails from. * @throws { Error } Throws an error if getting thumbnail data fails for any reason. * @return { Object } Collection of zero or more thumbnails from image. */ async getThumbnails(image) { const log = debug.extend('getThumbnails') const err = error.extend('getThumbnails') if (image) { await this.setPath(image) } if (this._path === null) { const msg = 'No image was specified to write new metadata content to.' err(msg) throw new Error() } this.setOutputFormat() this.clearShortcut() this.enableBinaryTagOutput(true) this.setMetadataTags('-Preview:all') log(this._command) let metadata try { metadata = await cmd(this._command) if (metadata.stderr !== '') { err(metadata.stderr) throw new Error(metadata.stderr) } metadata = JSON.parse(metadata.stdout) metadata.push({ exiftool_command: this._command }) metadata.push({ format: 'json' }) } catch (e) { err(e) e.exiftool_command = this._command return e } // log(metadata) return metadata } /** * Embed the given thumbnail data into the image. Optionally provide a specific metadata * tag target. * @summary Embed the given thumbnail data into the image. Optionally provide a specific * metadata tag target. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String } data - A resolved path to the thumbnail data. * @param { String } [image = null] - The target image to receive the thumbnail data. * @param { String } [tag = 'EXIF:ThumbnailImage'] - Optional destination tag, if other than * the default value. * @throws { Error } Throws an error if saving thumbnail data fails for any reason. * @return { Object } An object containing success or error messages, plus the exiftool * command used. */ async setThumbnail(data, image = null, tag = 'EXIF:ThumbnailImage') { const log = debug.extend('setThumbnail') const err = error.extend('setThumbnail') if (!data) { const msg = 'Missing required data parameter.' err(msg) throw new Error(msg) } if (image) { await this.setPath(image) } const dataPath = path.resolve(data) // this.setOverwriteOriginal(true) this.setOutputFormat() this.clearShortcut() this.setMetadataTags(`"-${tag}<=${dataPath}"`) log(this._command) let result try { result = await cmd(this._command) result.exiftool_command = this._command result.success = true } catch (e) { err(e) e.exiftool_command = this._command } log(result) return result } /** * Extract the raw XMP data as xmp-rdf packet. * @summary Extract the raw XMP data as xmp-rdf packet. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. */ async getXmpPacket() { const log = debug.extend('getXmpPacket') const err = error.extend('getXmpPacket') let packet try { const command = `${this._executable} ${this._opts.exiftool_config} -xmp -b ${this._path}` packet = await cmd(command) if (packet.stderr !== '') { err(packet.stderr) throw new Error(packet.stderr) } packet.exiftool_command = command // const parser = new fxp.XMLParser() // const builder = new fxp.XMLBuilder() // packet.xmp = builder.build(parser.parse(packet.stdout)) packet.xmp = packet.stdout delete packet.stdout delete packet.stderr } catch (e) { err(e) e.exiftool_command = this._command return e } log(packet) return packet } /** * Write a new metadata value to the designated tags. * @summary Write a new metadata value to the designated tags. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @param { String|String[] } metadataToWrite - A string value with tag name and new value * or an array of tag strings. * @throws { Error } Throws error if there is no valid path to an image file. * @throws { Error } Thros error if the current path is to a directory instead of a file. * @throws { Error } Thros error if the expected parameter is missing or of the wrong type. * @throws { Error } Thros error if exiftool returns a fatal error via stderr. * @return { Object|Error } Returns an object literal with success or error messages, or * throws an exception if no image given. */ async writeMetadataToTag(metadataToWrite) { const log = debug.extend('writeMetadataToTag') const err = error.extend('writeMetadataToTag') const o = { value: null, error: null } let tagString = '' if (this._path === null) { const msg = 'No image was specified to write new metadata content to.' err(msg) throw new Error() } if (this._isDirectory) { const msg = 'A directory was given. Use a path to a specific file instead.' err(msg) throw new Error(msg) } switch (metadataToWrite.constructor) { case Array: tagString = metadataToWrite.join(' ') break case String: tagString = metadataToWrite break default: throw new Error( 'Expected a string or an array of strings. ' + `Received: ${metadataToWrite.constructor}`, ) } try { log(`tagString: ${tagString}`) const file = `${this._path}` // const write = `${this._executable} ${this._opts.exiftool_config} ${tagString} ${file}` const write = `${this._executable} ` + `${this._opts.exiftool_config} ` + `${this._opts.overwrite_original} ` + `${tagString} ${file}` o.command = write const result = await cmd(write) if (result.stdout.trim() === null) { throw new Error(`Failed to write new metadata to image - ${file}`) } o.value = true o.stdout = result.stdout.trim() } catch (e) { err(e) o.error = e } return o } /** * Clear the metadata from a tag, but keep the tag rather than stripping it from the image * file. * @summary Clear the metadata from a tag, but keep the tag rather than stripping it from * the image file. * @author Matthew Duffy <mattduffy@gmail.com> * @async * @throws { Error } Throws error if there is no valid path to an image file. * @throws { Error } Throws error if the current path is to a directory instead of a file. * @throws { Error } Throws error if the expected parameter is missing or of the wrong type. * @throws { Error } Throws error if exiftool returns a fatal error via stderr. * @param { String|String[] } tagsToClear - A string value with tag name or an array of tag * names. * @return { Object|Error } Returns an object literal with success or error messages, or * throws an exception if no image given. */ async clearMetadataFromTag(tagsToClear) { const log = debug.extend('clearMetadataFromTag') const err = error.extend('clearMetadataFromTag') const o = { value: null, errors: null } let tagString = '' if (this._path === null) { const msg = 'No image was specified to clear metadata from tags.' err(msg) throw new Error(msg) } if (this._isDirectory) { const msg = 'No image was specified to write new metadata content to.' err(msg) throw new Error(msg) } let eMsg switch (tagsToClear.constructor) { case Array: tagString = tagsToClear.join(' ') break case String: tagString = tagsToClear break default: eMsg = `Expected a string or an arrray of strings. Recieved ${tagsToClear.constructor}` err(eMsg