/** * la classe qui repère des patterns */ import exifr from 'exifr' import moment from 'moment' import log from 'loglevel' import rangement_instance from '../conf/configs' import * as fs from "fs"; import path from "node:path"; import minimist from 'minimist'; import {fileDestructuration, taggingCommand} from './interfaces'; import child_process, {exec} from "child_process"; log.setLevel(rangement_instance.log_level) let cwd = path.dirname(process.cwd()) + '/' + path.basename(process.cwd()) const seconds_in_a_year: number = 1000 * 24 * 3600 * 30 * 12 /** * finds patterns for file name */ export default class finder { static statistics = { filesModified: 0, filesNotModified: 0, } private static moments: any[] = [] private static otherRenames: any = []; private static fileList: any = []; private static expandedFileList: any = []; private static mini_arguments: any; static readSubdirectories(baseDir: string) { fs.readdir(baseDir, (err, filesList) => { if (err) throw err log.debug(`readSubdirectories baseDir - ${baseDir} * files: `, filesList) filesList.forEach((subDirOrFile) => { const newFullPath = cwd + '/' + subDirOrFile if (fs.existsSync(newFullPath)) { const s = fs.statSync(newFullPath) if (s.isFile()) { this.expandedFileList.push(cwd + '/' + subDirOrFile) } } }) if (!filesList.length) { filesList = [] } this.fileList = filesList; return filesList; }) } static async isFolderOrFile(filePathName: string): Promise { const stat = fs.statSync(filePathName) if (stat.isDirectory()) { this.readSubdirectories(filePathName) log.debug('fileList in directory ', filePathName, '\n', this.fileList) if (this.fileList.length) { this.expandedFileList.push(...this.fileList) } } else if (stat.isFile()) { this.expandedFileList.push(filePathName) } } /** * guess file name on one file which is not a directory * @param fullPath */ static guessFileNameOnOnefile(fullPath: string): void { log.info('go guess file name on file: ', fullPath) fs.stat(fullPath, (err, stats) => { if (err) { log.error('échec fichier', err) log.error('ce fichier n existe pas: ', fullPath) return } else { log.debug(' -------- le fichier existe bien, déstructuration') let structureForFile: fileDestructuration = this.destructurateFileName(fullPath) log.debug(' -------- ', fullPath) log.debug(' -------- ', structureForFile) // examiner les infos exif de chaque fichier pour proposer un nouveau nom if (!structureForFile.dateStampInFileNameOriginal) { log.info(' le nom de fichier "' + structureForFile.freeText + '" ne contient pas de date formatée au début') } else { log.debug(' -------- dateStampInFileNameOriginal: ', structureForFile.dateStampInFileNameOriginal) } log.debug(' -------- trouver les infos exif', structureForFile.fullPath) this.findExifCreationDate(structureForFile.fullPath) .then(data => { log.info(' ... chercher la date de création : "' + structureForFile.freeText + '"') let foundDate = this.findEarliestDateInExifData(data) let gpsData = this.findGpsCoordinates(data) if (gpsData) { structureForFile.tags.push('has-gps') console.log('+++++++++++++++ has gps data', structureForFile.tags) } let isScreenshot = this.findScreenshot(structureForFile.fileNameOriginal) if (isScreenshot) { structureForFile.freeText.replace(`Capture d'écran`, '') structureForFile.freeText.replace(`Screenshot`, '') structureForFile.tags.push('screenshot') } log.info(' =>>>>>>> foundDate : ', foundDate) if (foundDate && (foundDate !== 'Invalid Date')) { structureForFile.dateStampExif = foundDate } else { log.info('pas de date trouvée dans le nom') } this.shouldWeChangeName(structureForFile) } , (error) => { log.warn('/////////// Error in reading exif of file: ' + error.message) return '' }) } }) } /** * if there is no original file name free text into the new name, append it to the free text part * @param originalFileName * @param fileMixedNewName */ static addOriginalFileNameIfMissing(originalFileName: string, fileMixedNewName: string) { if (!fileMixedNewName.includes(originalFileName)) { let properties = finder.destructurateFileName(fileMixedNewName) return properties.freeText + ' ' + originalFileName } else { return fileMixedNewName } } static reportStatistics() { log.info('\n --------- statistics', this.statistics) } static renameFile(originalFileName: string, fileMixedNewName: string) { if (rangement_instance.keepOriginalNameInRename) { log.debug(' +++++++++ on ajoute le nom original dans le free text +++++++', originalFileName) fileMixedNewName = this.addOriginalFileNameIfMissing(originalFileName, fileMixedNewName) log.info(' +++++++++ nouveau nom', fileMixedNewName) } let self = this; fs.rename(originalFileName, fileMixedNewName, function (err) { log.info(' ✅ name changed', fileMixedNewName) if (err) { log.info('rename ERROR: ' + err) } else { self.otherRenames.push(fileMixedNewName) finder.statistics['filesModified']++ // set the original file name self.setOriginalFileNameInExifData(originalFileName, fileMixedNewName).then(res=>{ log.debug('promise resolved setOriginalFileNameInExifData', res) }) } }) } /** * définit la valeur exif du nom original du fichier * @param filePath * @param newFileName */ static setOriginalFileNameInExifData(filePath, newFileName) { const command = `exiftool -OriginalFileName="${newFileName}" "${filePath}"`; console.log('setOriginalFileNameInExifData command', command) return new Promise((resolve, reject) => { exec(command, (error, stdout, stderr) => { if (error) { reject(error); } else { resolve(stdout); } }); }); } static guessFileNameOnAllFilesFromArguments(): void { // parcourir les fichiers log.debug('_____________ liste des fichiers', this.mini_arguments._) let fileList = this.mini_arguments._ // test file exists fileList.forEach((fullPath: string) => { log.debug('file list element: ', fullPath) // parcourir les dossiers this.isFolderOrFile(`${fullPath}`) } ) log.info('expanded file list :', this.expandedFileList) this.expandedFileList.forEach((filePath: string) => { this.guessFileNameOnOnefile(filePath) }) if (rangement_instance.reportStatistics || this.mini_arguments.stats) { finder.reportStatistics() } } static makeFileNameFromProperties(fileProperties: fileDestructuration) { let tagPlace = '' log.info('fileProperties.tags', fileProperties.tags) if (fileProperties.tags.length && rangement_instance.keepTags) { tagPlace = ' ' + rangement_instance.tagSectionSeparator + ' ' + fileProperties.tags.join(rangement_instance.tagSeparator) } log.debug('fileProperties.dateStampExif', fileProperties.dateStampExif) let newName = ''; let timestampFormatted = fileProperties.dateStampExif; /** * restauration de typologie de nom de fichier gopro pour faciliter les assemblages. */ if (this.mini_arguments['gopro-restore-name']) { this.mini_arguments['remove-timestamp'] = true; this.mini_arguments['remove-tags'] = true; } if (this.mini_arguments['remove-tags']) { tagPlace = ''; } if (this.mini_arguments['remove-timestamp']) { timestampFormatted = ''; } if (this.mini_arguments['remove-freetext']) { log.debug('override remove-freetext') rangement_instance.keepFreeText = false; } let extension = fileProperties.extension.trim(); if (this.mini_arguments['remove-extension']) { extension = ''; } let spacer = ''; if (timestampFormatted && fileProperties.freeText && rangement_instance.keepFreeText) { spacer = ' ' } /** * assemblage du schéma de nom */ newName += timestampFormatted + spacer + (rangement_instance.keepFreeText ? fileProperties.freeText : '') + tagPlace.trim() + extension if (rangement_instance.replaceUnderscoreWithSpaces) { newName = newName.replace('_', ' ') } newName = finder.cleanSpaces(newName) return newName } static parseArguments() { this.mini_arguments = minimist(process.argv.slice(2)) log.debug('arguments', this.mini_arguments) if (this.mini_arguments['lowerYear']) { log.info('année minimale à retenir pour la sélection des dates:', this.mini_arguments['lowerYear']) } if (this.mini_arguments['strictYear']) { log.info('année à retenir pour la sélection des dates:', this.mini_arguments['lowerYear']) } if (!this.mini_arguments._.length) { log.info('pas de fichier ou de dossier demandé, veuillez spécifier un chemin en argument') } console.log('this.mini_arguments', this.mini_arguments) } static shouldWeChangeName(structureForFile: fileDestructuration) { log.info(' ______ shouldWeChangeName ', structureForFile.fileNameOriginal) let newName = this.makeFileNameFromProperties(structureForFile) log.debug('newName', newName) if (this.otherRenames.includes(newName)) { log.debug('nouveau nom déjà présent dans les fichiers déjà renommés, on garde en état actuel') return; } if (structureForFile.fileNameOriginal !== newName) { log.info('\n ancien nom :', structureForFile.fileNameOriginal) log.info(' nouveau nom:', newName) if (!this.mini_arguments['dry-run']) { log.debug('___________ structureForFile.folderPath', structureForFile.folderPath) log.debug('___________ newName', newName) this.renameFile(structureForFile.fullPath, structureForFile.folderPath + newName) } else { log.info('no renaming for real, this is a dry run') finder.statistics['filesNotModified']++ } } else { log.info(' rien à changer') } } static findScreenshot(inputString: string) { return inputString.match(/screenshot/i) || inputString.match(/capture d'écran/i) } static findFormattedDate(filepath: string) { let match = filepath.match(/\d{4}-\d{2}-\d{2}T\d{2}\.\d{2}\.\d{2}/ig) log.debug(' finder - match findFormattedDate', match) let result = '' if (match && match[0]) { result = match[0] } return result } static findFileExtension(inputString: string): string { let result = inputString.match(/\.\w{3,4}$/i) if (result && result.length) { return result[0] } return '' } /** * find the section of file name which contains the free text to describe the picture * @param fileName * @returns {*|string} */ static findFileNameFreeTextPart(fileName: string) { fileName = fileName.replace(this.findFileExtension(fileName), '') let boom = fileName.split(rangement_instance.tagSectionSeparator) if (boom.length) { let freeTextPart = boom[0].trim() // remove date let foundDate = this.findFormattedDate(freeTextPart) if (foundDate) { freeTextPart = freeTextPart.replace(foundDate, '').trim() } log.debug(' finder - freeTextPart:', freeTextPart) return freeTextPart } return fileName.trim() } /** * find an array of tags * @param inputString * @returns {[]} */ static findTagSectionInString(inputString: string) { let listOfTags: Array = [] // remove extension let extensionFile = finder.findFileExtension(inputString) if (extensionFile) { inputString = inputString.replace(extensionFile, '') } else { log.debug(' finder - no extensionFile', extensionFile, inputString) extensionFile = '' } inputString = inputString.replace(extensionFile, '') log.debug(' finder - extensionFile', extensionFile) if (inputString.includes(rangement_instance.tagSectionSeparator)) { log.debug(' finder - inputString', inputString) if (inputString.length) { let boom = inputString.split(rangement_instance.tagSectionSeparator) log.debug(' finder - boom', boom) if (boom.length) { listOfTags = [...boom[1].trim().split(rangement_instance.tagSeparator)] log.debug(' finder - listOfTags', listOfTags) } else { log.debug(' finder - no boom', boom) } } } return listOfTags } static removeTagInProperties(properties: fileDestructuration, tagName: string) { let foundTagNameIndex = properties.tags.indexOf(tagName) if (foundTagNameIndex) { delete properties.tags[foundTagNameIndex] } return properties } static cleanSpaces(inputString: string) { return inputString.replace(/ *g/, ' ').replace(/ /, ' ').trim() } static searchAndReplaceInFileName(searchString: string, replaceString: string, fileName: string): string { return this.cleanSpaces(fileName.replace(searchString, replaceString)) } /** * search screenshot clues and rename */ static searchAndRenameScreenshots(fileName: string) { if (finder.findScreenshot(fileName)) { let tags = this.findTagSectionInString(fileName) log.debug(' finder - tags', tags) if (!tags.includes('screenshot')) { fileName = this.addTagInFileName('screenshot', fileName) fileName = this.searchAndReplaceInFileName('Screenshot', '', fileName) log.debug(' finder - screenShotMockFileName:', fileName) return this.cleanSpaces(fileName) } log.debug(' finder - is a screenshot, remove screenshot in name, and add tag screenshot') } else { return null } } /** * determines if we must add or remove new tags * @param tagCommand * @returns {{tagsToAdd: [], tagCommand: *, tagsToRemove: []}} */ static addOrRemoveTagsParsing(tagCommand: string): taggingCommand { let tagsToAdd: Array = [] let tagsToRemove: Array = [] // split all tags let listOfTags = tagCommand.split(' ') listOfTags.forEach(elem => { // remove when a minus is present* if (elem.indexOf('-')) { tagsToRemove.push(elem) } else { // add tag otherwise tagsToAdd.push(elem) } }) return {tagCommand, tagsToAdd, tagsToRemove} } static applyTagChangesOnProperties(tagChange: any, properties: fileDestructuration) { // add new tags properties.tags = [...properties.tags, tagChange.tagsToAdd] properties.tags.forEach((elem, index) => { if (tagChange.tagsToRemove.includes(elem)) { delete properties.tags[index] } }) properties.tags = [...new Set(properties.tags)]; return properties } /** * add a tag and gives new filename with extension * @param tagName * @param fileName * @returns {*} */ static addTagInFileName(tagName: string, fileName: string) { let tags: any = this.findTagSectionInString(fileName) let firstPart = this.findFileNameFreeTextPart(fileName) tags.push(tagName) // @ts-ignore let uniqueArray = [...new Set(tags)] let newFileName = firstPart + ' ' + rangement_instance.tagSectionSeparator + ' ' + uniqueArray.join(rangement_instance.tagSeparator) newFileName = newFileName.replace(/ {*}/, '') + this.findFileExtension(fileName) return this.cleanSpaces(newFileName) } /** * convertit un nom de fichier en une structure décrivant plusieurs parties correspondant au pattern d'archivage * @param fullPath * @returns {{extension: *, dateStamp: string, freeText: (*|string), tags: *[]}} */ static destructurateFileName(fullPath: string): fileDestructuration { let [folderPath, fileNameOriginal] = this.findFolderPath(fullPath) // if path is relative, add the current working directory as full path part if (/^\\(?!\\)/.test(fullPath)) { fullPath = cwd + '/' + fullPath } return { fullPath, folderPath, fileNameOriginal, dateStampInFileNameOriginal: this.findFormattedDate(fileNameOriginal), dateStampExif: '', freeText: this.findFileNameFreeTextPart(fileNameOriginal), tags: this.findTagSectionInString(fileNameOriginal), extension: this.findFileExtension(fileNameOriginal), } as fileDestructuration } static findGpsCoordinates(exifData: any) { let result = null; console.log('+++++++++++ findGpsCoordinates exifData', exifData) if (exifData ) { if( exifData.GPSDateTime || exifData.GPSLatitude || exifData.GPSLongitude){ result = true } } return result } static dateIsGood(date: string): boolean { console.log('dateIsGood ?', date) if (!date) { return false } let year: number = 1970; let date_as_timestamp = date * 1 console.log('date*1', date_as_timestamp) if (date_as_timestamp > seconds_in_a_year) { let dateObj: Date = new Date(date) if (!dateObj) { console.log("date is not valid") } console.log('date', date, dateObj) year = dateObj.getUTCFullYear(); } console.log('date : ', typeof date, year) console.log('(year > 1970)', (year > 1970)) let strictYearToFind = 1 * this.mini_arguments['strictYear'] let lowerYearPossible = 1 * this.mini_arguments['lowerYear'] if (!lowerYearPossible) { lowerYearPossible = 2004 } let today: Date = new Date(); let higherYearPossible = today.getFullYear() let isDateGood = (year === strictYearToFind) || ((year >= lowerYearPossible) && (year <= higherYearPossible)) console.log('lowerYearPossible', lowerYearPossible) if (!isDateGood) { log.warn(' date is not good :', date) } return isDateGood } private static pushToMomentDatesIfGood(someDate: Date): void { console.log('pushToMomentDatesIfGood someDate', someDate) if (this.dateIsGood(someDate)) { this.moments.push(someDate) } } /** * finds the earliest part in several exif date info * @param exifData * @returns {string} */ static findEarliestDateInExifData(exifData: any) { log.debug(' finder - findEarliestDateInExifData') this.moments = [] if (exifData) { let exifDates: string[] = ['DateTimeOriginal', 'FileModificationDateTime', 'FileModificationDate', 'ModificationDateTime', 'ModifyDate', 'FileAccessDateTime', 'DateTime', 'FileInodeChangeDateTime', 'CreateDate'] exifDates.forEach((exifDateKind: string): void => { if (exifData[exifDateKind]) { log.debug(' finder exifDates - : ', exifDateKind, exifData[exifDateKind]) this.pushToMomentDatesIfGood(exifData[exifDateKind]) } }) this.moments = this.moments.map(d => { let newdate = moment(d) return newdate }) let minDate = moment.min(this.moments) log.info(' finder - dates moments:', this.moments) log.debug(' finder - minDate :::::::::', minDate) log.info(' finder - minDate :::::::::', minDate.format(rangement_instance.iso_date_format)) return minDate.format(rangement_instance.iso_date_format) } else { log.debug(' finder - 😥 /!\\ pas de exif data') return '' } } static appendFileName(fileProperties: fileDestructuration, newText: string) { fileProperties.freeText = finder.cleanSpaces(fileProperties.freeText + ' ' + newText) return fileProperties } static prependFileName(fileProperties: fileDestructuration, newText: string) { fileProperties.freeText = finder.cleanSpaces(newText + ' ' + fileProperties.freeText) return fileProperties } /** * examine plusieurs propriétés exif de date et retourne la plus ancienne * @param filepath */ static async findExifCreationDate(filepath: string) { log.debug(' -------- findExifCreationDate') let dateAlreadyInFileName = finder.findFormattedDate(filepath) if (dateAlreadyInFileName) { log.debug(' finder - ------ dateAlreadyInFileName', dateAlreadyInFileName) } else { log.debug(' -------- pas de date présente') } let exifPromise = await exifr.parse(filepath); console.log(' -------- exifdata', exifPromise) return exifPromise } static findFolderPath(filePath: string) { let folders: any = filePath.split('/') let fileName: any = folders.pop() folders = filePath.replace(fileName, '') log.debug(' finder - \n - folders', folders) log.debug(' finder - - fileName', fileName, '\n') return [folders, fileName] } }