Skip to content

Instantly share code, notes, and snippets.

@tlhunter
Created January 21, 2024 22:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tlhunter/5f46700d3e638c4cf62f62980ecd0512 to your computer and use it in GitHub Desktop.
Save tlhunter/5f46700d3e638c4cf62f62980ecd0512 to your computer and use it in GitHub Desktop.
Script to help delete unwanted RAW files with a Darktable workflow
#!/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