import * as fs from 'fs'
import tinify from 'tinify'
import { calculateFileHash, findAllImageFiles } from './fileUtils.js'
import { readCompressionRecord } from './recordUtils.js'
/**
* Validate TinyPNG API key.
*
* @param apiKey The API key to validate.
* @returns Promise that resolves if key is valid.
*/
export async function validateApiKey(apiKey: string): Promise<void> {
return new Promise((resolve, reject) => {
tinify.key = apiKey
tinify.validate((err: any) => {
if (err) {
reject(new Error(`Invalid API key: ${err.message}`))
} else {
resolve()
}
})
})
}
/**
* Compress a single image file using TinyPNG.
*
* @param filePath The path to the image file.
* @returns Promise that resolves when compression is complete.
*/
export async function compressImage(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const source = tinify.fromFile(filePath)
source.toFile(filePath, (err: any) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/**
* Get all image files with smart duplicate detection (supports nested folders).
*
* @param directoryPath The path to the directory.
* @returns Object containing categorized image files and detection results.
*/
export async function getImageFiles(directoryPath: string): Promise<{
allImageFiles: Array<{ fullPath: string, relativePath: string }>
filesToCompress: Array<{ fullPath: string, relativePath: string }>
alreadyCompressed: Array<{ fullPath: string, relativePath: string }>
renamedFiles: Array<{ currentPath: string, originalPath: string }>
replacedFiles: Array<{ relativePath: string, reason: string }>
}> {
/* Find all image files recursively */
const allImageFiles = await findAllImageFiles(directoryPath)
/* Read existing compression record */
const record = await readCompressionRecord(directoryPath)
const compressedFiles = record?.compressedFiles ?? {}
const filesToCompress: Array<{ fullPath: string, relativePath: string }> = []
const alreadyCompressed: Array<{ fullPath: string, relativePath: string }> = []
const renamedFiles: Array<{ currentPath: string, originalPath: string }> = []
const replacedFiles: Array<{ relativePath: string, reason: string }> = []
/* Create reverse hash lookup for detecting renamed/moved files */
const hashToRelativePath = new Map<string, string>()
for (const [relativePath, info] of Object.entries(compressedFiles)) {
hashToRelativePath.set(info.hash, relativePath)
}
for (const imageFile of allImageFiles) {
const { fullPath, relativePath } = imageFile
const recordInfo = compressedFiles[relativePath]
if (recordInfo) {
/* Relative path exists in record, verify if it's the same file */
try {
const currentStats = await fs.promises.stat(fullPath)
const currentSize = currentStats.size
const currentMtime = currentStats.mtime.getTime()
/* Quick metadata check first */
if (currentSize === recordInfo.size && currentMtime === recordInfo.mtime) {
/* Very likely the same file, skip hash calculation */
alreadyCompressed.push(imageFile)
continue
}
/* Metadata differs, perform hash verification */
const currentHash = await calculateFileHash(fullPath)
if (currentHash === recordInfo.hash) {
/* Same content, just metadata changed */
alreadyCompressed.push(imageFile)
continue
} else {
/* Different content - new file replaced the old one */
filesToCompress.push(imageFile)
replacedFiles.push({
relativePath,
reason: 'New file replaced existing compressed file (content changed)',
})
continue
}
} catch (error) {
/* Error accessing file, treat as new file */
filesToCompress.push(imageFile)
replacedFiles.push({
relativePath,
reason: `Could not verify file integrity: ${error instanceof Error ? error.message : 'Unknown error'}`,
})
continue
}
} else {
/* Relative path not in record, check if it's a renamed/moved file */
try {
const currentHash = await calculateFileHash(fullPath)
const originalRelativePath = hashToRelativePath.get(currentHash)
if (originalRelativePath) {
/* This is a renamed/moved file! */
alreadyCompressed.push(imageFile)
renamedFiles.push({
currentPath: relativePath,
originalPath: originalRelativePath,
})
continue
} else {
/* Truly new file */
filesToCompress.push(imageFile)
continue
}
} catch (error) {
/* Hash calculation failed, treat as new file */
filesToCompress.push(imageFile)
continue
}
}
}
return {
allImageFiles,
filesToCompress,
alreadyCompressed,
renamedFiles,
replacedFiles,
}
}