628 lines
18 KiB
TypeScript
628 lines
18 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
log.setLevel(rangement_instance.log_level)
|
|
|
|
|
|
let cwd = path.dirname(process.cwd()) + '/' + path.basename(process.cwd())
|
|
|
|
|
|
/**
|
|
* 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 - 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)
|
|
}
|
|
}
|
|
})
|
|
|
|
return filesList;
|
|
})
|
|
}
|
|
|
|
static isFolderOrFile(filePathName: string) {
|
|
const stat = fs.statSync(filePathName)
|
|
|
|
if (stat.isDirectory()) {
|
|
this.fileList = 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')
|
|
}
|
|
|
|
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.debug(' +++++++++ 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']++
|
|
}
|
|
})
|
|
}
|
|
|
|
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<string> = []
|
|
// 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<any> = []
|
|
let tagsToRemove: Array<any> = []
|
|
|
|
// 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;
|
|
if (exifData && exifData.GPSDateTime) {
|
|
result = exifData.GPSDateTime
|
|
}
|
|
return result
|
|
}
|
|
|
|
static dateIsGood(date: Date): boolean {
|
|
if (!date) {
|
|
return false
|
|
}
|
|
let year: number = date.getFullYear();
|
|
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 {
|
|
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);
|
|
// log.debug(' -------- 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]
|
|
|
|
}
|
|
|
|
}
|