Skip to content

Instantly share code, notes, and snippets.

@tlhunter
Created January 21, 2024 22:21
Show Gist options
  • Save tlhunter/5f46700d3e638c4cf62f62980ecd0512 to your computer and use it in GitHub Desktop.
Save tlhunter/5f46700d3e638c4cf62f62980ecd0512 to your computer and use it in GitHub Desktop.

Revisions

  1. tlhunter created this gist Jan 21, 2024.
    136 changes: 136 additions & 0 deletions delete-raw-files.mjs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,136 @@
    #!/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;
    }