diff --git a/README.md b/README.md index 2f67e8e..778ae74 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,58 @@ Best takes can be exported to a folder using `File > Export Best Takes` or with ![images/export_to.png](images/export_to.png) +## CLI Usage + +Cullergrader can be run in command-line mode for automated workflows and scripting. + +### Basic Usage + +```bash +# Launch GUI (no arguments) +java -jar cullergrader.jar + +# Run CLI mode +java -jar cullergrader.jar --input /path/to/photos --output /path/to/export +``` + +### CLI Options + +| Option | Short | Description | Required | +|--------|-------|-------------|----------| +| `--input` | `-i` | Input folder containing photos | Yes | +| `--output` | `-o` | Output folder for best takes (preview mode if omitted) | No | +| `--json` | `-j` | Export group information to JSON file | No | +| `--time` | `-t` | Time threshold in seconds (default: 15) | No | +| `--similarity` | `-s` | Similarity threshold 0-100 (default: 45) | No | +| `--help` | `-h` | Show help message | No | + +### Examples + +**Preview mode (no export)**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation +``` + +**Export to folder**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation --output ~/photos/best +``` + +**Custom thresholds**: +```bash +java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best -t 10 -s 40 +``` + +**Export JSON metadata only**: +```bash +java -jar cullergrader.jar --input ~/photos/vacation --json groups.json +``` + +**Export both files and JSON**: +```bash +java -jar cullergrader.jar -i ~/photos/vacation -o ~/photos/best --json ~/photos/best/groups.json +``` + ## Config ### Default Config ```json diff --git a/src/main/java/com/penguinpush/cullergrader/CLI.java b/src/main/java/com/penguinpush/cullergrader/CLI.java new file mode 100644 index 0000000..8fe020b --- /dev/null +++ b/src/main/java/com/penguinpush/cullergrader/CLI.java @@ -0,0 +1,305 @@ +package com.penguinpush.cullergrader; + +import com.penguinpush.cullergrader.logic.*; +import com.penguinpush.cullergrader.media.*; +import com.penguinpush.cullergrader.config.AppConstants; + +import java.io.File; +import java.util.List; + +/** + * Command-line interface for Cullergrader. + * Provides photo grouping and export functionality without launching the GUI. + */ +public class CLI { + + // Exit codes + private static final int EXIT_SUCCESS = 0; + private static final int EXIT_FAILURE = 1; + + // Parsed arguments with defaults from AppConstants + private String inputPath = null; + private String outputPath = null; + private String jsonPath = null; + private float timeThreshold = AppConstants.TIME_THRESHOLD_SECONDS; + private float similarityThreshold = AppConstants.SIMILARITY_THRESHOLD_PERCENT; + + /** + * Main entry point for CLI mode. + * + * @param args Command-line arguments + * @return Exit code (0 = success, 1 = failure) + */ + public int run(String[] args) { + // Handle --help first + if (hasArgument(args, "--help") || hasArgument(args, "-h")) { + printHelp(); + return EXIT_SUCCESS; + } + + // Parse arguments + if (!parseArguments(args)) { + System.err.println("Error: Invalid arguments. Use --help for usage information."); + return EXIT_FAILURE; + } + + // Validate required arguments + if (inputPath == null) { + System.err.println("Error: --input is required."); + printHelp(); + return EXIT_FAILURE; + } + + // Validate input directory + File inputFolder = new File(inputPath); + if (!inputFolder.exists() || !inputFolder.isDirectory()) { + System.err.println("Error: Input directory does not exist: " + inputPath); + return EXIT_FAILURE; + } + + if (!inputFolder.canRead()) { + System.err.println("Error: Cannot read input directory: " + inputPath); + return EXIT_FAILURE; + } + + // Validate output directory if provided + File outputFolder = null; + if (outputPath != null) { + outputFolder = new File(outputPath); + if (outputFolder.exists() && !outputFolder.isDirectory()) { + System.err.println("Error: Output path exists but is not a directory: " + outputPath); + return EXIT_FAILURE; + } + + if (outputFolder.exists() && !outputFolder.canWrite()) { + System.err.println("Error: Cannot write to output directory: " + outputPath); + return EXIT_FAILURE; + } + } + + // Execute workflow + try { + executeWorkflow(inputFolder, outputFolder); + return EXIT_SUCCESS; + } catch (Exception e) { + System.err.println("Error: Processing failed - " + e.getMessage()); + e.printStackTrace(); + return EXIT_FAILURE; + } + } + + /** + * Parses command-line arguments. + * + * @param args Command-line arguments + * @return true if parsing succeeded, false on error + */ + private boolean parseArguments(String[] args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + + // Input path + if (arg.equals("--input") || arg.equals("-i")) { + if (i + 1 >= args.length) { + System.err.println("Error: --input requires a value"); + return false; + } + inputPath = args[++i]; + } + // Output path + else if (arg.equals("--output") || arg.equals("-o")) { + if (i + 1 >= args.length) { + System.err.println("Error: --output requires a value"); + return false; + } + outputPath = args[++i]; + } + // JSON export path + else if (arg.equals("--json") || arg.equals("-j")) { + if (i + 1 >= args.length) { + System.err.println("Error: --json requires a value"); + return false; + } + jsonPath = args[++i]; + } + // Time threshold + else if (arg.equals("--time") || arg.equals("-t")) { + if (i + 1 >= args.length) { + System.err.println("Error: --time requires a value"); + return false; + } + try { + timeThreshold = Float.parseFloat(args[++i]); + if (timeThreshold <= 0) { + System.err.println("Error: Time threshold must be positive"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid time threshold value: " + args[i]); + return false; + } + } + // Similarity threshold + else if (arg.equals("--similarity") || arg.equals("-s")) { + if (i + 1 >= args.length) { + System.err.println("Error: --similarity requires a value"); + return false; + } + try { + similarityThreshold = Float.parseFloat(args[++i]); + if (similarityThreshold < 0 || similarityThreshold > 100) { + System.err.println("Error: Similarity threshold must be 0-100"); + return false; + } + } catch (NumberFormatException e) { + System.err.println("Error: Invalid similarity threshold value: " + args[i]); + return false; + } + } + // Skip --help and -h (handled in run method) + else if (arg.equals("--help") || arg.equals("-h")) { + // Already handled in run(), just skip + } + // Unknown argument + else if (arg.startsWith("-")) { + System.err.println("Error: Unknown argument: " + arg); + return false; + } + } + + return true; + } + + /** + * Executes the main CLI workflow: load photos, generate groups, and export. + * + * @param inputFolder Input directory containing photos + * @param outputFolder Output directory for best takes + */ + private void executeWorkflow(File inputFolder, File outputFolder) { + long startTime = System.currentTimeMillis(); + boolean previewMode = (outputFolder == null); + + // Print configuration header + System.out.println("Cullergrader CLI"); + System.out.println("================"); + System.out.println("Input: " + inputFolder.getAbsolutePath()); + if (!previewMode) { + System.out.println("Output: " + outputFolder.getAbsolutePath()); + } else { + System.out.println("Mode: Preview (no files will be exported)"); + } + System.out.println("Time threshold: " + timeThreshold + " seconds"); + System.out.println("Similarity threshold: " + similarityThreshold + "%"); + System.out.println(); + + // Load and hash photos + System.out.println("Loading and hashing photos from: " + inputFolder.getAbsolutePath()); + GroupingEngine engine = new GroupingEngine(); + List photos = engine.photoListFromFolder(inputFolder); + + if (photos.isEmpty()) { + System.out.println("No photos found in input directory."); + return; + } + + System.out.println("Found " + photos.size() + " photos"); + System.out.println(); + + // Generate groups + System.out.println("Generating groups with thresholds: " + timeThreshold + "s time, " + similarityThreshold + "% similarity"); + List groups = engine.generateGroups(photos, timeThreshold, similarityThreshold); + + System.out.println("Created " + groups.size() + " groups from " + photos.size() + " photos"); + System.out.println(); + + // Export JSON if requested + if (jsonPath != null) { + File jsonFile = new File(jsonPath); + System.out.println("Exporting group information to: " + jsonFile.getAbsolutePath()); + FileUtils.exportGroupsJson(groups, jsonFile, timeThreshold, similarityThreshold); + System.out.println(); + } + + // Export or preview + if (previewMode) { + System.out.println("Preview - Best takes that would be exported:"); + System.out.println("--------------------------------------------"); + for (int i = 0; i < groups.size(); i++) { + PhotoGroup group = groups.get(i); + Photo bestTake = group.getBestTake(); + if (bestTake != null) { + System.out.println("[Group " + i + "] " + bestTake.getFile().getName()); + } + } + System.out.println(); + System.out.println("To export these " + groups.size() + " files, run again with --output "); + } else { + System.out.println("Exporting best takes to: " + outputFolder.getAbsolutePath()); + FileUtils.exportBestTakes(groups, outputFolder); + System.out.println(); + System.out.println("Successfully exported " + groups.size() + " files"); + } + + // Summary + long endTime = System.currentTimeMillis(); + long durationMs = endTime - startTime; + double durationSec = durationMs / 1000.0; + System.out.println(); + System.out.println("Processing completed in " + String.format("%.2f", durationSec) + " seconds"); + } + + /** + * Prints help message showing usage and available options. + */ + private void printHelp() { + System.out.println("Cullergrader CLI - Photo grouping and export tool"); + System.out.println(); + System.out.println("USAGE:"); + System.out.println(" java -jar cullergrader.jar [OPTIONS]"); + System.out.println(); + System.out.println(" No arguments launches GUI mode"); + System.out.println(); + System.out.println("OPTIONS:"); + System.out.println(" -i, --input Input folder containing photos (required)"); + System.out.println(" -o, --output Output folder for best takes (optional, preview mode if omitted)"); + System.out.println(" -j, --json Export group information to JSON file (optional)"); + System.out.println(" -t, --time Time threshold in seconds (default: " + AppConstants.TIME_THRESHOLD_SECONDS + ")"); + System.out.println(" -s, --similarity Similarity threshold 0-100 (default: " + AppConstants.SIMILARITY_THRESHOLD_PERCENT + ")"); + System.out.println(" -h, --help Show this help message"); + System.out.println(); + System.out.println("EXAMPLES:"); + System.out.println(" # Preview mode (no export)"); + System.out.println(" java -jar cullergrader.jar --input /photos"); + System.out.println(); + System.out.println(" # Export mode"); + System.out.println(" java -jar cullergrader.jar --input /photos --output /export"); + System.out.println(); + System.out.println(" # Export JSON metadata only"); + System.out.println(" java -jar cullergrader.jar --input /photos --json groups.json"); + System.out.println(); + System.out.println(" # Export both files and JSON"); + System.out.println(" java -jar cullergrader.jar -i /photos -o /export --json /export/groups.json"); + System.out.println(); + System.out.println(" # Custom thresholds with export"); + System.out.println(" java -jar cullergrader.jar -i /photos -o /export -t 10 -s 40"); + System.out.println(); + } + + /** + * Helper method to check if a specific flag is present in arguments. + * Used by Main.java to detect CLI mode. + * + * @param args Command-line arguments + * @param flag Flag to search for + * @return true if flag is present, false otherwise + */ + public static boolean hasArgument(String[] args, String flag) { + for (String arg : args) { + if (arg.equals(flag)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/penguinpush/cullergrader/Main.java b/src/main/java/com/penguinpush/cullergrader/Main.java index bae4efa..f5eb719 100644 --- a/src/main/java/com/penguinpush/cullergrader/Main.java +++ b/src/main/java/com/penguinpush/cullergrader/Main.java @@ -10,17 +10,44 @@ public class Main { public static void main(String[] args) { - // load theme - if (AppConstants.DARK_THEME) { - FlatDarculaLaf.setup(); + // Detect CLI mode by checking for CLI-specific arguments + if (isCLIMode(args)) { + // CLI mode - skip GUI initialization + CLI cli = new CLI(); + int exitCode = cli.run(args); + System.exit(exitCode); } else { - FlatIntelliJLaf.setup(); + // GUI mode + // load theme + if (AppConstants.DARK_THEME) { + FlatDarculaLaf.setup(); + } else { + FlatIntelliJLaf.setup(); + } + + GroupingEngine groupingEngine = new GroupingEngine(); + ImageLoader imageLoader = new ImageLoader(); + + SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); + GroupGridFrame.initializeLoggerCallback(); } + } - GroupingEngine groupingEngine = new GroupingEngine(); - ImageLoader imageLoader = new ImageLoader(); + /** + * Determines if the application should run in CLI mode based on command-line arguments. + * + * @param args Command-line arguments + * @return true if CLI mode should be used, false for GUI mode + */ + private static boolean isCLIMode(String[] args) { + if (args.length == 0) { + return false; + } - SwingUtilities.invokeLater(() -> new GroupGridFrame(imageLoader, groupingEngine)); - GroupGridFrame.initializeLoggerCallback(); + // Check for CLI-specific flags + return CLI.hasArgument(args, "--input") || + CLI.hasArgument(args, "-i") || + CLI.hasArgument(args, "--help") || + CLI.hasArgument(args, "-h"); } } diff --git a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java index f3cfc2a..6896691 100644 --- a/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java +++ b/src/main/java/com/penguinpush/cullergrader/logic/FileUtils.java @@ -4,13 +4,20 @@ import com.penguinpush.cullergrader.media.PhotoGroup; import static com.penguinpush.cullergrader.utils.Logger.logMessage; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + import javax.swing.*; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class FileUtils { @@ -40,4 +47,67 @@ public static void exportBestTakes(List photoGroups, File targetFold } } } + + public static void exportGroupsJson(List photoGroups, File jsonFile, + float timeThreshold, float similarityThreshold) { + // Create parent directories if needed + File parentDir = jsonFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + + // Build JSON structure + Map root = new HashMap<>(); + root.put("totalGroups", photoGroups.size()); + root.put("totalPhotos", photoGroups.stream().mapToInt(PhotoGroup::getSize).sum()); + root.put("exportTimestamp", System.currentTimeMillis()); + + Map thresholds = new HashMap<>(); + thresholds.put("timeThresholdSeconds", timeThreshold); + thresholds.put("similarityThresholdPercent", similarityThreshold); + root.put("thresholds", thresholds); + + List> groupsList = new ArrayList<>(); + for (PhotoGroup group : photoGroups) { + Map groupMap = new HashMap<>(); + groupMap.put("groupIndex", group.getIndex()); + groupMap.put("photoCount", group.getSize()); + + Photo bestTake = group.getBestTake(); + if (bestTake != null) { + groupMap.put("bestTakeFilename", bestTake.getFile().getName()); + } + + List> photosList = new ArrayList<>(); + for (Photo photo : group.getPhotos()) { + Map photoMap = new HashMap<>(); + photoMap.put("filename", photo.getFile().getName()); + photoMap.put("path", photo.getPath()); + photoMap.put("timestamp", photo.getTimestamp()); + photoMap.put("hash", photo.getHash()); + photoMap.put("isBestTake", photo.isBestTake()); + + List metrics = photo.getMetrics(); + if (metrics.size() >= 2) { + photoMap.put("deltaTimeSeconds", metrics.get(0)); + photoMap.put("similarityPercent", metrics.get(1)); + } + + photosList.add(photoMap); + } + groupMap.put("photos", photosList); + groupsList.add(groupMap); + } + root.put("groups", groupsList); + + // Write JSON file with pretty printing + try (FileWriter writer = new FileWriter(jsonFile)) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + gson.toJson(root, writer); + logMessage("Exported group information to: " + jsonFile.getAbsolutePath()); + } catch (IOException e) { + logMessage("Failed to export JSON: " + e.getMessage()); + throw new RuntimeException("Failed to export JSON", e); + } + } } diff --git a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java index f702a40..859fbeb 100644 --- a/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java +++ b/src/main/java/com/penguinpush/cullergrader/ui/GroupGridFrame.java @@ -103,6 +103,7 @@ private void initComponents() { jMenu = new javax.swing.JMenu(); jMenuItemOpen = new javax.swing.JMenuItem(); jMenuItemExport = new javax.swing.JMenuItem(); + jMenuItemExportJson = new javax.swing.JMenuItem(); setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); setTitle("Cullergrader"); @@ -149,6 +150,14 @@ public void actionPerformed(java.awt.event.ActionEvent evt) { }); jMenu.add(jMenuItemExport); + jMenuItemExportJson.setText("Export Group Information (JSON)"); + jMenuItemExportJson.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + jMenuItemExportJsonActionPerformed(evt); + } + }); + jMenu.add(jMenuItemExportJson); + jMenuBar.add(jMenu); setJMenuBar(jMenuBar); @@ -214,6 +223,62 @@ private void jMenuItemExportActionPerformed(java.awt.event.ActionEvent evt) {//G } }//GEN-LAST:event_jMenuItemExportActionPerformed + private void jMenuItemExportJsonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jMenuItemExportJsonActionPerformed + if (photoGroups == null || photoGroups.isEmpty()) { + JOptionPane.showMessageDialog( + null, + "No groups to export. Please open a folder first.", + "No Groups", + JOptionPane.WARNING_MESSAGE + ); + return; + } + + JFileChooser chooser = new JFileChooser(importDirectory != null ? importDirectory : new File(AppConstants.DEFAULT_FOLDER_PATH)); + chooser.setDialogTitle("Export Group Information (JSON)"); + chooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + chooser.setSelectedFile(new File("groups.json")); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON files", "json")); + + int result = chooser.showSaveDialog(null); + if (result == JFileChooser.APPROVE_OPTION) { + File selectedFile = chooser.getSelectedFile(); + + // Ensure .json extension + final File jsonFile; + if (!selectedFile.getName().toLowerCase().endsWith(".json")) { + jsonFile = new File(selectedFile.getPath() + ".json"); + } else { + jsonFile = selectedFile; + } + + try { + float timeThreshold = (float) jTimestampSpinner.getValue(); + float similarityThreshold = (float) jSimilaritySpinner.getValue(); + + FileUtils.exportGroupsJson(photoGroups, jsonFile, timeThreshold, similarityThreshold); + + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog( + null, + "Group information exported to:\n" + jsonFile.getAbsolutePath(), + "Export Successful!", + JOptionPane.INFORMATION_MESSAGE + ); + }); + } catch (Exception e) { + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog( + null, + "Failed to export JSON: " + e.getMessage(), + "Export Failed", + JOptionPane.ERROR_MESSAGE + ); + }); + } + } + }//GEN-LAST:event_jMenuItemExportJsonActionPerformed + private void jMenuItemOpenActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_jMenuItemOpenActionPerformed JFileChooser chooser = new JFileChooser(importDirectory != null ? importDirectory : new File(AppConstants.DEFAULT_FOLDER_PATH)); chooser.setDialogTitle("Open Folder..."); @@ -261,6 +326,7 @@ private void jReloadButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN private javax.swing.JMenu jMenu; private javax.swing.JMenuBar jMenuBar; private javax.swing.JMenuItem jMenuItemExport; + private javax.swing.JMenuItem jMenuItemExportJson; private javax.swing.JMenuItem jMenuItemOpen; private javax.swing.JButton jReloadButton; private javax.swing.JLabel jSimilarityLabel;