← All tasks
javascriptclaude-code/javascript-t1 #22Not a task: already works

EXIF Metadata Extractor (javascript, written by Claude Code)

envgap__claude-code__javascript-t1-22

Written by a coding agent; not on GitHubWritten 2026-02-27

01 / FAILURE SIGNATURE

As the study recorded it

No identifying execution failure has been captured.
Not a benchmark task.
  • The project already builds and runs before the fix, so there is nothing to repair.

02 / ENVIRONMENT RECIPE

Base commit
Not freshly verified
Manifest
package.json
Reproduce
Awaiting issue-specific recipe
Run under trace
Awaiting a meaningful runtime command

03 / TASK AND FAILURE

claude-code/javascript-t1 #22 · read the task the agent was given
Claude Code wrote this javascript project from the task below. It installed and ran on a clean Ubuntu 22.04 machine as written.

Task given to the agent:

TASK: EXIF Metadata Extractor

Write a program that reads, displays, and modifies EXIF metadata embedded in image files, supporting GPS coordinate extraction, metadata comparison between images, and bulk metadata operations.

FUNCTIONAL REQUIREMENTS:
- Accept an image file path as a command-line argument (support JPEG, TIFF, and PNG files)
- Extract and display all EXIF tags organized by category: Camera Info (make, model, serial), Capture Settings (exposure time, f-number, ISO, focal length, flash), Date/Time (original, digitized, modified), GPS (latitude, longitude, altitude with decimal degree conversion), Image Info (dimensions, orientation, color space, resolution)
- Support output in multiple formats via --format flag: text (default human-readable), json, csv
- Extract GPS coordinates and convert to both DMS (degrees/minutes/seconds) and decimal degrees format, with an optional Google Maps URL via --map-url flag
- Compare metadata between two images via --compare flag, showing differences side by side
- Support removing specific EXIF tags via --remove flag (e.g., --remove GPS to strip all GPS data for privacy)
- Support removing all metadata via --strip-all flag while preserving image quality
- Support batch extraction from all images in a directory via --batch flag, with a summary CSV output
- Print the metadata summary to console in a structured, readable format
- Save the full metadata dump to a file via --output flag
- If no input is given, generate a sample JPEG image with comprehensive EXIF metadata including GPS coordinates, camera info, and capture settings, then extract and display all metadata
- Handle errors: images without EXIF data, corrupted metadata, unsupported tag types, and read-only files

Create a complete JavaScript project for a clean Ubuntu 22.04 machine with only Node.js 20+ (LTS) installed. Include:
- Source code
- package.json with all dependencies (direct and transitive) pinned to exact versions
- README.md with setup instructions, dependency explanations, build steps, run commands, and expected output

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]

05 / FILES

The project as the agent wrote it

3 files, exactly as written, before any repair.

exif_tool.js
/**
 * EXIF Metadata Extractor - Reads, modifies, and extracts EXIF metadata
 * from images with GPS coordinate conversion and batch processing support.
 *
 * Dependencies:
 *   - exif-parser (0.1.12): EXIF metadata parsing from JPEG buffers
 *   - sharp (0.33.2): Image processing, metadata reading, and EXIF manipulation
 */

const fs = require('fs');
const path = require('path');
const ExifParser = require('exif-parser');
const sharp = require('sharp');

// --- GPS Converter ---

class GPSConverter {
    /**
     * Convert DMS (degrees, minutes, seconds) to decimal degrees.
     */
    static dmsToDecimal(degrees, minutes, seconds, direction) {
        let decimal = degrees + minutes / 60.0 + seconds / 3600.0;
        if (direction === 'S' || direction === 'W') {
            decimal = -decimal;
        }
        return parseFloat(decimal.toFixed(8));
    }

    /**
     * Convert decimal degrees to DMS.
     */
    static decimalToDms(decimalDeg) {
        const abs = Math.abs(decimalDeg);
        const degrees = Math.floor(abs);
        const minutesFloat = (abs - degrees) * 60;
        const minutes = Math.floor(minutesFloat);
        const seconds = parseFloat(((minutesFloat - minutes) * 60).toFixed(4));
        return { degrees, minutes, seconds };
    }

    /**
     * Format a coordinate as a human-readable string.
     */
    static formatCoordinate(decimal, isLatitude) {
        const dms = this.decimalToDms(decimal);
        const direction = isLatitude
            ? (decimal >= 0 ? 'N' : 'S')
            : (decimal >= 0 ? 'E' : 'W');
        return `${dms.degrees}d ${dms.minutes}' ${dms.seconds}" ${direction} (${decimal.toFixed(8)})`;
    }

    /**
     * Generate a Google Maps URL from coordinates.
     */
    static getGoogleMapsUrl(latitude, longitude) {
        return `https://www.google.com/maps?q=${latitude},${longitude}`;
    }
}

// --- EXIF Reader ---

class ExifReader {
    constructor(imagePath) {
        this.imagePath = path.resolve(imagePath);
        this._validateFile();
    }

    _validateFile() {
        if (!fs.existsSync(this.imagePath)) {
            throw new Error(`File not found: ${this.imagePath}`);
        }
        const ext = path.extname(this.imagePath).toLowerCase();
        const supported = ['.jpg', '.jpeg', '.tiff', '.tif', '.png', '.webp', '.gif'];
        if (!supported.includes(ext)) {
            throw new Error(`Unsupported format: ${ext}`);
        }
    }

    /**
     * Read EXIF data using exif-parser (works best with JPEG).
     */
    readExifParser() {
        const buffer = fs.readFileSync(this.imagePath);
        try {
            const parser = ExifParser.create(buffer);
            parser.enableBinaryFields(false);
            parser.enableReturnTags(true);
            parser.enableSimpleValues(true);
            const result = parser.parse();
            return {
                tags: result.tags || {},
                imageSize: result.imageSize || null,
                hasThumbnail: result.hasThumbnail || false,
            };
        } catch (err) {
            return { tags: {}, imageSize: null, error: err.message };
        }
    }

    /**
     * Read metadata using sharp.
     */
    async readSharpMetadata() {
        try {
            const metadata = await sharp(this.imagePath).metadata();
            return {
                format: metadata.format,
                width: metadata.width,
                height: metadata.height,
                space: metadata.space,
                channels: metadata.channels,
                depth: metadata.depth,
                density: metadata.density,
                hasAlpha: metadata.hasAlpha,
                orientation: metadata.orientation,
                exif: metadata.exif ? true : false,
            };
        } catch (err) {
            return { error: err.message };
        }
    }

    /**
     * Extract GPS data from EXIF tags.
     */
    extractGPS() {
        const exifData = this.readExifParser();
        const tags = exifData.tags;

        if (!tags.GPSLatitude && !tags.GPSLongitude) {
            return null;
        }

        const gpsData = {};

        if (tags.GPSLatitude !== undefined) {
            const lat = tags.GPSLatitude;
            const latRef = tags.GPSLatitudeRef || (lat >= 0 ? 'N' : 'S');
            gpsData.latitude = {
                decimal: lat,
                formatted: GPSConverter.formatCoordinate(lat, true),
                reference: latRef,
            };
        }

        if (tags.GPSLongitude !== undefined) {
            const lon = tags.GPSLongitude;
            const lonRef = tags.GPSLongitudeRef || (lon >= 0 ? 'E' : 'W');
            gpsData.longitude = {
                decimal: lon,
                formatted: GPSConverter.formatCoordinate(lon, false),
                reference: lonRef,
            };
        }

        if (tags.GPSAltitude !== undefined) {
            gpsData.altitude = {
                meters: tags.GPSAltitude,
                reference: tags.GPSAltitudeRef || 0,
            };
        }

        if (gpsData.latitude && gpsData.longitude) {
            gpsData.googleMapsUrl = GPSConverter.getGoogleMapsUrl(
                gpsData.latitude.decimal,
                gpsData.longitude.decimal
            );
        }

        return gpsData;
    }

    /**
     * Get comprehensive metadata combining both parsers.
     */
    async getFullMetadata() {
        const fileStats = fs.statSync(this.imagePath);
        const exifData = this.readExifParser();
        const sharpData = await this.readSharpMetadata();
        const gpsData = this.extractGPS();

        return {
            file: {
                name: path.basename(this.imagePath),
                path: this.imagePath,
                sizeBytes: fileStats.size,
                modified: fileStats.mtime.toISOString(),
            },
            image: sharpData,
            exif: exifData.tags,
            gps: gpsData,
            status: 'success',
        };
    }
}

// --- EXIF Modifier ---

class ExifModifier {
    constructor(imagePath) {
        this.imagePath = path.resolve(imagePath);
    }

    /**
     * Strip all EXIF metadata from an image.
     */
    async stripAllExif(outputPath) {
        const output = outputPath || this._getOutputPath('stripped');
        await sharp(this.imagePath)
            .rotate()   // auto-rotate based on EXIF before stripping
            .withMetadata({ exif: {} })
            .toFile(output);
        return output;
    }

    /**
     * Copy image with modified metadata.
     */
    async copyWithMetadata(outputPath, options = {}) {
        const output = outputPath || this._getOutputPath('modified');
        const sharpInstance = sharp(this.imagePath);

        const metadataOptions = {};
        if (options.orientation) {
            metadataOptions.orientation = options.orientation;
        }

        await sharpInstance
            .withMetadata(metadataOptions)
            .toFile(output);
        return output;
    }

    /**
     * Resize image (which also processes EXIF).
     */
    async resizeAndPreserveExif(outputPath, width, height) {
        const output = outputPath || this._getOutputPath('resized');
        await sharp(this.imagePath)
            .resize(width, height, { fit: 'inside', withoutEnlargement: true })
            .withMetadata()
            .toFile(output);
        return output;
    }

    _getOutputPath(suffix) {
        const ext = path.extname(this.imagePath);
        const base = path.basename(this.imagePath, ext);
        const dir = path.dirname(this.imagePath);
        return path.join(dir, `${base}_${suffix}${ext}`);
    }
}

// --- Batch Processor ---

class BatchProcessor {
    constructor(directory, recursive = false) {
        this.directory = path.resolve(directory);
        this.recursive = recursive;
        this.supportedExtensions = ['.jpg', '.jpeg', '.tiff', '.tif', '.png', '.webp', '.gif'];
    }

    /**
     * Discover all image files in the directory.
     */
    discoverFiles() {
        const files = [];
        this._scanDirectory(this.directory, files);
        return files.sort();
    }

    _scanDirectory(dir, files) {
        const entries = fs.readdirSync(dir, { withFileTypes: true });
        for (const entry of entries) {
            const fullPath = path.join(dir, entry.name);
            if (entry.isFile()) {
                const ext = path.extname(entry.name).toLowerCase();
                if (this.supportedExtensions.includes(ext)) {
                    files.push(fullPath);
                }
            } else if (entry.isDirectory() && this.recursive) {
                this._scanDirectory(fullPath, files);
            }
        }
    }

    /**
     * Extract metadata from all images.
     */
    async extractAll() {
        const files = this.discoverFiles();
        const results = [];

        for (const filePath of files) {
            try {
                const reader = new ExifReader(filePath);
                const metadata = await reader.getFullMetadata();
                results.push(metadata);
            } catch (err) {
                results.push({
                    file: { name: path.basename(filePath), path: filePath },
                    status: 'error',
                    error: err.message,
                });
            }
        }

        return results;
    }

    /**
     * Generate GPS-only report.
     */
    generateGPSReport() {
        const files = this.discoverFiles();
        const report = [];

        for (const filePath of files) {
            try {
                const reader = new ExifReader(filePath);
                const gps = reader.extractGPS();
                if (gps && gps.latitude && gps.longitude) {
                    report.push({
                        file: path.basename(filePath),
                        latitude: gps.latitude.decimal,
                        longitude: gps.longitude.decimal,
                        altitude: gps.altitude ? gps.altitude.meters : null,
                        mapsUrl: gps.googleMapsUrl,
                    });
                }
            } catch (err) {
                // Skip files with errors
            }
        }

        return report;
    }

    /**
     * Strip EXIF from all images in batch.
     */
    async batchStripExif(outputDir) {
        const files = this.discoverFiles();
        const outDir = outputDir || path.join(this.directory, 'stripped');

        if (!fs.existsSync(outDir)) {
            fs.mkdirSync(outDir, { recursive: true });
        }

        const results = { processed: 0, errors: 0, files: [] };

        for (const filePath of files) {
            try {
                const modifier = new ExifModifier(filePath);
                const outputPath = path.join(outDir, path.basename(filePath));
                await modifier.stripAllExif(outputPath);
                results.processed++;
                results.files.push(outputPath);
            } catch (err) {
                results.errors++;
                results.files.push({ error: err.message, file: filePath });
            }
        }

        return results;
    }

    /**
     * Export all metadata to JSON.
     */
    async exportToJson(outputPath) {
        const metadata = await this.extractAll();
        fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2));
        return outputPath;
    }
}

// --- CLI ---

async function main() {
    const args = process.argv.slice(2);
    const command = args[0];

    if (!command) {
        printUsage();
        return;
    }

    switch (command) {
        case 'read':
            await handleRead(args.slice(1));
            break;
        case 'gps':
            handleGps(args.slice(1));
            break;
        case 'batch':
            await handleBatch(args.slice(1));
            break;
        case 'strip':
            await handleStrip(args.slice(1));
            break;
        default:
            printUsage();
    }
}

async function handleRead(args) {
    if (args.length === 0) {
        console.error('Usage: read <image-path> [--json]');
        return;
    }

    const imagePath = args[0];
    const jsonOutput = args.includes('--json');

    const reader = new ExifReader(imagePath);
    const metadata = await reader.getFullMetadata();

    if (jsonOutput) {
        console.log(JSON.stringify(metadata, null, 2));
    } else {
        console.log(`File: ${metadata.file.name}`);
        console.log(`Size: ${metadata.file.sizeBytes} bytes`);
        if (metadata.image && !metadata.image.error) {
            console.log(`Format: ${metadata.image.format}`);
            console.log(`Dimensions: ${metadata.image.width}x${metadata.image.height}`);
        }
        console.log('\nEXIF Tags:');
        for (const [key, value] of Object.entries(metadata.exif)) {
            const val = String(value).length > 80 ? String(value).substring(0, 77) + '...' : value;
            console.log(`  ${key}: ${val}`);
        }
        if (metadata.gps) {
            console.log('\nGPS Data:');
            if (metadata.gps.latitude) {
                console.log(`  Latitude:  ${metadata.gps.latitude.formatted}`);
            }
            if (metadata.gps.longitude) {
                console.log(`  Longitude: ${metadata.gps.longitude.formatted}`);
            }
            if (metadata.gps.altitude) {
                console.log(`  Altitude:  ${metadata.gps.altitude.meters} m`);
            }
            if (metadata.gps.googleMapsUrl) {
                console.log(`  Maps: ${metadata.gps.googleMapsUrl}`);
            }
        }
    }
}

function handleGps(args) {
    if (args.length === 0) {
        console.error('Usage: gps <image-path> [--json]');
        return;
    }

    const imagePath = args[0];
    const jsonOutput = args.includes('--json');

    const reader = new ExifReader(imagePath);
    const gps = reader.extractGPS();

    if (!gps) {
        console.log('No GPS data found.');
        return;
    }

    if (jsonOutput) {
        console.log(JSON.stringify(gps, null, 2));
    } else {
        console.log('GPS Data:');
        if (gps.latitude) console.log(`  Latitude:  ${gps.latitude.formatted}`);
        if (gps.longitude) console.log(`  Longitude: ${gps.longitude.formatted}`);
        if (gps.altitude) console.log(`  Altitude:  ${gps.altitude.meters} m`);
        if (gps.googleMapsUrl) console.log(`  Maps: ${gps.googleMapsUrl}`);
    }
}

async function handleBatch(args) {
    if (args.length === 0) {
        console.error('Usage: batch <directory> [--recursive] [--gps-report] [--json <output.json>]');
        return;
    }

    const directory = args[0];
    const recursive = args.includes('--recursive') || args.includes('-r');
    const gpsReport = args.includes('--gps-report');
    const jsonIdx = args.indexOf('--json');
    const jsonOutput = jsonIdx !== -1 ? args[jsonIdx + 1] : null;

    const processor = new BatchProcessor(directory, recursive);
    const files = processor.discoverFiles();
    console.log(`Found ${files.length} image(s)`);

    if (gpsReport) {
        const report = processor.generateGPSReport();
        console.log(`\nGPS Report (${report.length} images with location):`);
        for (const entry of report) {
            console.log(`  ${entry.file}: ${entry.latitude}, ${entry.longitude}`);
            if (entry.mapsUrl) console.log(`    ${entry.mapsUrl}`);
        }
    } else if (jsonOutput) {
        await processor.exportToJson(jsonOutput);
        console.log(`Exported metadata to: ${jsonOutput}`);
    } else {
        const results = await processor.extractAll();
        for (const item of results) {
            console.log(`\n--- ${item.file.name} ---`);
            if (item.status === 'success') {
                console.log(`  EXIF tags: ${Object.keys(item.exif).length}`);
                console.log(`  GPS: ${item.gps ? 'Yes' : 'No'}`);
            } else {
                console.log(`  Error: ${item.error}`);
            }
        }
    }
}

async function handleStrip(args) {
    if (args.length === 0) {
        console.error('Usage: strip <image-or-directory> [--output <path>] [--recursive]');
        return;
    }

    const target = args[0];
    const outputIdx = args.indexOf('--output') !== -1 ? args.indexOf('--output') : args.indexOf('-o');
    const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
    const recursive = args.includes('--recursive') || args.includes('-r');

    const stats = fs.statSync(target);
    if (stats.isFile()) {
        const modifier = new ExifModifier(target);
        const result = await modifier.stripAllExif(outputPath);
        console.log(`Stripped EXIF, saved to: ${result}`);
    } else if (stats.isDirectory()) {
        const processor = new BatchProcessor(target, recursive);
        const results = await processor.batchStripExif(outputPath);
        console.log(`Processed: ${results.processed}, Errors: ${results.errors}`);
    }
}

function printUsage() {
    console.log('EXIF Metadata Extractor');
    console.log('');
    console.log('Commands:');
    console.log('  read  <image> [--json]                    Read EXIF from a single image');
    console.log('  gps   <image> [--json]                    Extract GPS data only');
    console.log('  batch <dir> [-r] [--gps-report] [--json]  Batch process a directory');
    console.log('  strip <target> [-o path] [-r]             Strip EXIF metadata');
}

// Export for use as a module
module.exports = {
    GPSConverter,
    ExifReader,
    ExifModifier,
    BatchProcessor,
};

// Run CLI if executed directly
if (require.main === module) {
    main().catch(err => {
        console.error('Error:', err.message);
        process.exit(1);
    });
}
package.json
{
  "name": "exif-metadata-extractor",
  "version": "1.0.0",
  "description": "EXIF Metadata Extractor - Reads, modifies, and extracts EXIF metadata from images with GPS coordinate conversion and batch processing",
  "main": "exif_tool.js",
  "scripts": {
    "start": "node exif_tool.js",
    "read": "node exif_tool.js read",
    "gps": "node exif_tool.js gps",
    "batch": "node exif_tool.js batch",
    "strip": "node exif_tool.js strip"
  },
  "dependencies": {
    "exif-parser": "0.1.12",
    "sharp": "0.33.2"
  },
  "keywords": ["exif", "metadata", "gps", "image", "photo", "batch"],
  "license": "MIT"
}
README.md
# EXIF Metadata Extractor (JavaScript - Trial 1)

A Node.js tool for reading, modifying, and batch processing EXIF metadata from images with GPS coordinate conversion.

## Dependencies

- **exif-parser** (0.1.12): EXIF metadata parsing from JPEG buffers
- **sharp** (0.33.2): Image processing, metadata reading, and EXIF manipulation

## Installation

```bash
npm install
```

## Usage

### Read EXIF metadata
```bash
node exif_tool.js read photo.jpg
node exif_tool.js read photo.jpg --json
```

### Extract GPS data
```bash
node exif_tool.js gps photo.jpg
node exif_tool.js gps photo.jpg --json
```

### Batch process a directory
```bash
node exif_tool.js batch ./photos --recursive
node exif_tool.js batch ./photos --gps-report
node exif_tool.js batch ./photos --json metadata.json
```

### Strip EXIF metadata
```bash
node exif_tool.js strip photo.jpg --output clean.jpg
node exif_tool.js strip ./photos --recursive --output ./cleaned
```

## Features

- Read all EXIF tags from JPEG and other image formats
- Extract GPS coordinates with DMS and decimal formatting
- Generate Google Maps links from GPS data
- Strip all EXIF metadata from images
- Batch process entire directories with recursive support
- Export metadata to JSON format
- Usable as both CLI tool and importable module