Created
January 21, 2024 22:21
-
-
Save tlhunter/5f46700d3e638c4cf62f62980ecd0512 to your computer and use it in GitHub Desktop.
Script to help delete unwanted RAW files with a Darktable workflow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env zx | |
/** | |
* `npm install -g zx` | |
* | |
* I take photos on a Sony camera in RAW+JPG mode. | |
* I then scroll through all the photos in Darktable usually at least once. | |
* When I really like a photo I export it as DSC001.export.jpg or DSC001.insta.jpg. | |
* Sometimes I rate photos in Darktable but not always. | |
* | |
* At this point a directory contains a bunch of files named like this: | |
* DSC001.JPG | |
* DSC001.ARW | |
* DSC001.ARW.xmp | |
* DSC001.export.jpg | |
* DSC001.insta.jpg | |
* | |
* So to know if a file is worth keeping it will have a DSC001.*.jpg file. | |
* Or, the DSC001.ARW.xmp file will have some sort of rating metadata. | |
* | |
* This script deletes *.ARW and *.ARW.xmp files deemed as not worth keeping. | |
*/ | |
// Minimal rating to keep a photo. 1 means keep everything, 5 means keep perfect, etc | |
const MIN_RATING = Number(argv['rating']) || 1; | |
// pass --dry-run to print files to be deleted instead of deleting them | |
const DRY_RUN = !!argv['dry-run'] || false; | |
// which directory to examine | |
const DIR = argv['dir'] || process.cwd(); | |
// default extension (TODO: make this work for any format) | |
const RAW_EXT = '.' + (argv['ext'] || 'ARW').toLowerCase(); | |
// files exceeding this edit count won't be deleted | |
const MAX_EDITS = Number(argv['max-edits']) || Infinity | |
const files_array = await fs.readdir(DIR); | |
const files_all_casings = new Set(files_array); | |
for (let file of files_array) { | |
files_all_casings.add(file.toLowerCase()); | |
} | |
const prefixes = []; | |
const prefix_to_real_filenames = new Map(); | |
for (let file of files_array) { | |
const normalized = file.toLowerCase(); // dsc001.arw | |
const prefix = file.split('.')[0]; // DSC001 | |
if (path.extname(normalized) === RAW_EXT) { | |
prefixes.push(prefix); | |
prefix_to_real_filenames.set(prefix, { // DSC001 | |
prefix, | |
filename: file, // DSC001.ARW | |
darktable: null, // DSC001.ARW.xmp | |
export: null, // DSC001.*.jpg | |
jpg: null, // DSC001.jpg | |
rating: 0, // 1 - 5 | |
mods: 0, // number of Darktable edits, min seems to be 11 | |
}); | |
} | |
} | |
for (let file of files_array) { | |
const normalized = file.toLowerCase(); // dsc001.arw | |
if (path.extname(normalized) === RAW_EXT) continue; // looking at raw again | |
const prefix = file.split('.')[0]; // DSC001 | |
const prefix_obj = prefix_to_real_filenames.get(prefix); | |
if (!prefix_obj) continue; | |
if (normalized.match(/^.+\..+\.jpg$/)) { | |
prefix_obj.export = file; | |
} else if (path.extname(normalized) === '.xmp') { | |
prefix_obj.darktable = file; | |
const rating = await getRatingFromDarktableFile(file); | |
prefix_obj.rating = rating; | |
const mod_count = await getNumberOfModifications(file); | |
prefix_obj.mods = mod_count; | |
} else if (normalized === `${prefix.toLowerCase()}.jpg`) { | |
prefix_obj.jpg = file; | |
} | |
} | |
for (const photo of prefix_to_real_filenames.values()) { | |
if (!photo.jpg) { | |
console.warn(chalk.blue(`${photo.filename}: KEEP: NO MATCH JPG`)); | |
continue; | |
} | |
if (photo.rating >= MIN_RATING) { | |
console.warn(chalk.blue(`${photo.filename}: KEEP: RATING ${photo.rating}/5`)); | |
continue; | |
} | |
if (photo.export) { | |
console.warn(chalk.blue(`${photo.filename}: KEEP: HAS EXPORT ${photo.export}`)); | |
continue; | |
} | |
if (photo.mods >= MAX_EDITS) { | |
console.warn(chalk.blue(`${photo.filename}: KEEP: HAS ${photo.mods} EDITS`)); | |
continue; | |
} | |
if (DRY_RUN) { | |
console.log(chalk.yellow(`${photo.filename}: SKIP DELETE FOR DRY RUN`)); | |
} else { | |
await sendToTrash(photo.filename); | |
await sendToTrash(photo.darktable); | |
if (photo.rating < 0) { // -1 means rejected. it sucks so much we delete the JPG | |
// TODO: This should run regardless of prior checks | |
await sendToTrash(photo.jpg); | |
} | |
} | |
} | |
async function sendToTrash(filename) { | |
console.log(chalk.red(`${filename}: DELETE`)); | |
await $`gio trash ${filename}` | |
} | |
async function getRatingFromDarktableFile(darktable_filename) { | |
const content = (await fs.readFile(darktable_filename)).toString(); | |
const match = content.match(/xmp:Rating="([-0-9]+)"/); | |
if (!match) return 0; | |
return Number(match[1]); | |
} | |
async function getNumberOfModifications(darktable_filename) { | |
const content = (await fs.readFile(darktable_filename)).toString(); | |
const match = content.match(/<rdf:li/g); | |
if (!match) return 0; | |
return match.length; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment