Files
obsidian-excalidraw-plugin/ea-scripts/Image Occlusion.md
trillstones 6098e1b42e add setting - Generate Images No Matter What
change card's and folder's naming logic
2024-12-08 17:29:00 +08:00

41 KiB
Raw Permalink Blame History

/*

Image Occlusion for Excalidraw

This script creates image occlusion cards similar to Anki's Image Occlusion Enhanced plugin.

Usage:

  1. Insert an image into Excalidraw
  2. Draw rectangles or ellipses over areas you want to occlude
  3. Select the image and all shapes you want to use as masks
  4. Run this script
  5. Choose occlusion mode:
    • Add Cards: Hide One, Guess One: Creates cards where only one shape is hidden at a time
    • Add Cards: Hide All, Guess One: Creates cards where all shapes are hidden except one
    • 🗑️ Delete Cards: Delete old cards (add DELETE marker): Marks all existing cards for deletion by adding DELETE marker
    • 🗑️💥 Delete Cards: Delete old cards file and related images (Be Cautious!! Physical Delection): Permanently deletes all related card files and images

The script will generate masked versions of the image and save them locally.

*/

// Check minimum required version of Excalidraw plugin
if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("1.9.0")) {
  new Notice("This script requires a newer version of Excalidraw. Please install the latest version.");
  return;
}

// Get all selected elements from the canvas
const elements = ea.getViewSelectedElements();

// Find all selected image elements
const selectedImages = elements.filter(el => el.type === "image");

// Get all non-image elements to use as masks
const maskElements = elements.filter(el => el.type !== "image");

// Group masks based on their grouping in Excalidraw
const maskGroups = ea.getMaximumGroups(maskElements);

// Process each mask or group of masks
const masks = maskGroups.map(group => {
  // If group contains only one element, return that element
  if (group.length === 1) return group[0];
  // If group contains multiple elements, return the group info
  return {
    type: "group",
    elements: group,
    id: group[0].groupIds?.[0] || ea.generateElementId()
  };
});

// Validate selection - must have one image and at least one mask
if(selectedImages.length === 0 || masks.length === 0) {
  new Notice("Please select at least one image and one element or group to use as mask");
  return;
}

// Verify the selected image and masks are properly grouped
const validateSelection = () => {
  // Get combined bounds of all selected images
  const combinedBounds = selectedImages.reduce((bounds, img) => ({
    minX: Math.min(bounds.minX, img.x),
    maxX: Math.max(bounds.maxX, img.x + img.width),
    minY: Math.min(bounds.minY, img.y),
    maxY: Math.max(bounds.maxY, img.y + img.height)
  }), {
    minX: Infinity,
    maxX: -Infinity,
    minY: Infinity,
    maxY: -Infinity
  });
  
  // Remove bounds checking and always return true
  return true;
};

// Validate selection before proceeding
if (!validateSelection()) {
  return;
}

// Present user with operation mode choices
const mode = await utils.suggester(
  [
    "⭐⠀      Add Cards:    Hide One, Guess One",
    "⭐⭐     Add Cards:    Hide All, Guess One",
    "🗑️⠀      Delete Cards: Delete old cards (add DELETE marker)",
    "🗑️💥     Delete Cards: Delete old cards files and related images (Be Cautious!!)"
  ],
  ["hideOne", "hideAll", "delete", "deleteFiles"],
  "Select operation mode"
);

// Exit if user cancels the operation
if(!mode) return;

// Function to permanently delete related files and images
const deleteRelatedFilesAndImages = async (sourcePath) => {
  // Add delay function for async operations
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  
  // Initialize collections and counters
  const cardFiles = new Set();
  const batchMarkers = new Set();
  const sourceFile = app.vault.getAbstractFileByPath(sourcePath);
  let deletedCardsCount = 0;
  let deletedFoldersCount = 0;
  
  if (!sourceFile) {
    new Notice(`Source file not found: ${sourcePath}`);
    return;
  }
  
  // Get backlinks to find batch-marker.md files
  const backlinks = app.metadataCache.getBacklinksForFile(sourceFile) || new Map();
  
  // Find all batch-marker.md files that link to the source file
  if (backlinks.data instanceof Map) {
    for (const [filePath, _] of backlinks.data.entries()) {
      if (filePath.endsWith('batch-marker.md')) {
        const markerFile = app.vault.getAbstractFileByPath(filePath);
        if (markerFile) {
          batchMarkers.add(markerFile);
          //  console.log(`Found batch marker: ${filePath}`);
        }
      }
    }
  }
  
  if (batchMarkers.size === 0) {
    //  console.log('No batch markers found. Please check if the source file path is correct:', sourcePath);
    return;
  }
  
  // Process each batch marker file to find cards
  for (const marker of batchMarkers) {
    // console.log(`Processing batch marker: ${marker.path}`);
    const content = await app.vault.read(marker);
    // console.log("Batch marker content:", content);
    const lines = content.split('\n');
    
    // Find the "Generated Cards:" section
    const startIndex = lines.findIndex(line => line.trim() === 'Generated Cards:');
    // console.log("Start index:", startIndex);
    if (startIndex !== -1) {
      // Process each card link after the "Generated Cards:" line
      for (let i = startIndex + 1; i < lines.length; i++) {
        // console.log("Processing line:", lines[i]);
        const match = lines[i].match(/\[\[([^\]]+)\]\]/);
        if (match) {
          const cardPath = match[1];
          // Use Obsidian's API to resolve wiki link
          const cardFile = app.metadataCache.getFirstLinkpathDest(cardPath, marker.path);
          
          if (cardFile) {
            cardFiles.add(cardFile);
            // console.log(`Found card file through wiki link: ${cardFile.path}`);
          } else {
            // console.log(`Card file not found for wiki link: ${cardPath}`);
          }
        }
      }
    }
  }
  
  // First delete all card files
  for (const file of cardFiles) {
    try {
      if (await app.vault.adapter.exists(file.path)) {
        // Notify Obsidian's event system about the deletion
        app.vault.trigger("delete", file);
        await app.vault.delete(file);
        // Add short delay to allow plugins to respond
        await delay(50);
        deletedCardsCount++;
        //  console.log(`Deleted card file: ${file.path}`);
      }
    } catch (error) {
      console.error(`Failed to delete card file: ${file.path}`, error);
    }
  }
  
   // Wait for file deletion operations to complete
  await delay(200);

  // Then delete batch marker folders
  for (const marker of batchMarkers) {
    const parentPath = marker.path.substring(0, marker.path.lastIndexOf('/'));
    const parentFolder = app.vault.getAbstractFileByPath(parentPath);
    
    if (parentFolder && await app.vault.adapter.exists(parentFolder.path)) {
      try {
        // Notify folder deletion
        app.vault.trigger("delete", parentFolder);
        await app.vault.delete(parentFolder, true);
        await delay(50);
        deletedFoldersCount++;
        //  console.log(`Deleted folder: ${parentFolder.path}`);
      } catch (error) {
        console.error(`Failed to delete folder: ${parentFolder.path}`, error);
      }
    }
  }
  
  new Notice(`Summary:
  - Card files deleted: ${deletedCardsCount}
  - Image folders deleted: ${deletedFoldersCount}`);
};

// Function to get batch markers and their parent folders
const getBatchMarkersInfo = async (sourceFile) => {
  const backlinks = app.metadataCache.getBacklinksForFile(sourceFile) || new Map();
  const batchMarkers = new Map(); // Map<folderPath, Set<markerFile>>
  
  if (backlinks.data instanceof Map) {
    for (const [filePath, _] of backlinks.data.entries()) {
      if (filePath.endsWith('batch-marker.md')) {
        const markerFile = app.vault.getAbstractFileByPath(filePath);
        if (markerFile) {
          const folderPath = markerFile.path.substring(0, markerFile.path.lastIndexOf('/'));
          if (!batchMarkers.has(folderPath)) {
            batchMarkers.set(folderPath, new Set());
          }
          batchMarkers.get(folderPath).add(markerFile);
        }
      }
    }
  }
  
  return batchMarkers;
};

// Function to find and mark cards for deletion
const deleteRelatedCards = async (sourcePath, selectedFolders = null) => {
  const cardFiles = new Set();
  const sourceFile = app.vault.getAbstractFileByPath(sourcePath);
  let totalCardsFound = 0;
  let totalNewlyMarked = 0;
  let totalAlreadyMarked = 0;
  
  if (!sourceFile) {
    console.log(`Source file not found: ${sourcePath}`);
    return;
  }
  
  // Get all batch markers grouped by folder
  const batchMarkersMap = await getBatchMarkersInfo(sourceFile);
  
  if (batchMarkersMap.size === 0) {
    console.log('No batch markers found');
    return;
  }
  
  // Get batch markers to process
  let batchMarkersToProcess = new Set();
  if (selectedFolders) {
    // Convert to array if it's not already
    const folderArray = Array.isArray(selectedFolders) ? selectedFolders : [selectedFolders];
    
    // Process each selected folder
    folderArray.forEach(folder => {
      const markers = batchMarkersMap.get(folder);
      if (markers) {
        markers.forEach(marker => batchMarkersToProcess.add(marker));
      }
    });
  } else {
    // Process all markers
    batchMarkersMap.forEach(markers => {
      markers.forEach(marker => batchMarkersToProcess.add(marker));
    });
  }
  
  // Process each batch marker file
  for (const marker of batchMarkersToProcess) {
    // console.log(`Processing batch marker: ${marker.path}`);
    const content = await app.vault.read(marker);
    // console.log("Batch marker content:", content);
    const lines = content.split('\n');
    
    // Find the "Generated Cards:" section
    const startIndex = lines.findIndex(line => line.trim() === 'Generated Cards:');
    // console.log("Start index:", startIndex);
    if (startIndex !== -1) {
      // Process each card link after the "Generated Cards:" line
      for (let i = startIndex + 1; i < lines.length; i++) {
        // console.log("Processing line:", lines[i]);
        const match = lines[i].match(/\[\[([^\]]+)\]\]/);
        if (match) {
          const cardPath = match[1];
          // Use Obsidian's API to resolve wiki link
          const cardFile = app.metadataCache.getFirstLinkpathDest(cardPath, marker.path);
          
          if (cardFile) {
            cardFiles.add(cardFile);
            //  console.log(`Found card file through wiki link: ${cardFile.path}`);
          } else {
            console.log(`Card file not found for wiki link: ${cardPath}`);
          }
        }
      }
    }
  }
  
  // Process each card file to add DELETE markers
  for (const file of cardFiles) {
    // console.log("Processing card file:", file.path);
    // Read file content and split into lines for processing
    const content = await app.vault.read(file);
    // console.log("Card content:", content);
    const lines = content.split('\n');
    let modified = false;
    let cardCount = 0;
    let alreadyMarkedCount = 0;
    
    // Search for Anki card IDs and add DELETE marker before each
    for (let i = 0; i < lines.length; i++) {
      // Look for Anki card ID pattern
      const idMatch = lines[i].match(/<!--ID:.+?-->/);
      if (idMatch) {
        // console.log("Found ID line:", lines[i]);
        cardCount++;
        const cardId = idMatch[0];
        
        // Check if DELETE marker already exists
        if (i > 0 && lines[i-1].trim() === 'DELETE') {
          // console.log("DELETE marker already exists");
          alreadyMarkedCount++;
          continue;
        }
        
        // Insert DELETE marker before the ID line
        lines.splice(i, 0, 'DELETE');
        i++; // Skip the newly inserted line
        modified = true;
        // console.log("Added DELETE marker before:", cardId);
      }
    }
    
    // Save changes if file was modified
    if (modified) {
      // console.log("Saving modified content");
      await app.vault.modify(file, lines.join('\n'));
    } else {
      // console.log("No modifications needed");
    }
    
    totalCardsFound += cardCount;
    totalNewlyMarked += (cardCount - alreadyMarkedCount);
    totalAlreadyMarked += alreadyMarkedCount;
  }
  
  new Notice(`Summary:
  - Files processed: ${cardFiles.size}
  - Total cards found: ${totalCardsFound}
  - Newly marked for deletion: ${totalNewlyMarked}
  - Already marked for deletion: ${totalAlreadyMarked}`);
};

// If delete files mode is selected, delete all related files and exit
if(mode === "deleteFiles") {
  // Show confirmation dialog before permanent deletion
  const confirmed = await utils.suggester(
    ["Delete all files", "Select folders to delete"],
    ["all", "select"],
    "WARNING: This will permanently delete all related card files and image folders. This action cannot be undone. Are you sure?"
  );
  
  if (!confirmed) {
    new Notice("Operation cancelled");
    return;
  }
  
  const currentFile = app.workspace.getActiveFile();
  if (currentFile) {
    // Get all batch markers and their folders
    const batchMarkersMap = await getBatchMarkersInfo(currentFile);
    
    if (batchMarkersMap.size === 0) {
      new Notice("No files found to delete");
      return;
    }
    
    if (confirmed === "select") {
      // Sort folders alphabetically
      const folders = Array.from(batchMarkersMap.keys()).sort();
      
      // Let user select folders
      let selectedFolders = await utils.suggester(
        folders,
        folders,
        "Select folders to delete (ESC to cancel)",
        true  // Allow multi-select
      );
      
      if (!selectedFolders || selectedFolders.length === 0) return;
      
      // Ensure selectedFolders is an array
      if (!Array.isArray(selectedFolders)) {
        selectedFolders = [selectedFolders];
      }
      
      // Delete files from selected folders
      for (const folder of selectedFolders) {
        const markers = batchMarkersMap.get(folder);
        if (markers) {
          for (const marker of markers) {
            // Process each batch marker
            const content = await app.vault.read(marker);
            const lines = content.split('\n');
            const startIndex = lines.findIndex(line => line.trim() === 'Generated Cards:');
            
            if (startIndex !== -1) {
              // Delete card files first
              for (let i = startIndex + 1; i < lines.length; i++) {
                const match = lines[i].match(/\[\[([^\]]+)\]\]/);
                if (match) {
                  const cardPath = match[1];
                  const cardFile = app.metadataCache.getFirstLinkpathDest(cardPath, marker.path);
                  if (cardFile) {
                    try {
                      await app.vault.delete(cardFile);
                      // console.log(`Deleted card file: ${cardFile.path}`);
                    } catch (error) {
                      console.error(`Failed to delete card file: ${cardFile.path}`, error);
                    }
                  }
                }
              }
              
              // Then delete the folder
              const parentFolder = app.vault.getAbstractFileByPath(folder);
              if (parentFolder) {
                try {
                  await app.vault.delete(parentFolder, true);
                  //  console.log(`Deleted folder: ${folder}`);
                } catch (error) {
                  console.error(`Failed to delete folder: ${folder}`, error);
                }
              }
            }
          }
        }
      }
      
      new Notice(`Successfully deleted selected folders and their contents`);
    } else {
      // Delete all files
      const currentFile = app.workspace.getActiveFile();
      if (currentFile) {
        await deleteRelatedFilesAndImages(currentFile.path);
      }
    }
  } else {
    new Notice("No source file found");
  }
  return;
}

// If delete mode is selected, mark old cards for deletion and exit
if(mode === "delete") {
  const currentFile = app.workspace.getActiveFile();
  if (currentFile) {
    // Get all batch markers and their folders
    const batchMarkersMap = await getBatchMarkersInfo(currentFile);
    
    if (batchMarkersMap.size === 0) {
      new Notice("No cards found to delete");
      return;
    }
    
    // Ask user whether to delete all or select folders
    const deleteChoice = await utils.suggester(
      ["Delete all cards", "Select folders to delete"],
      ["all", "select"],
      "How would you like to delete cards?"
    );
    
    if (!deleteChoice) return;
    
    if (deleteChoice === "select") {
      // Sort folders alphabetically
      const folders = Array.from(batchMarkersMap.keys()).sort();
      
      // Let user select folders
      let selectedFolders = await utils.suggester(
        folders,
        folders,
        "Select folders to delete cards from (ESC to cancel)",
        true  // Allow multi-select
      );
      
      if (!selectedFolders || selectedFolders.length === 0) return;
      
      // Ensure selectedFolders is an array
      if (!Array.isArray(selectedFolders)) {
        selectedFolders = [selectedFolders];
      }
      
      // Delete cards from selected folders
      await deleteRelatedCards(currentFile.path, selectedFolders);
    } else {
      // Delete all cards
      await deleteRelatedCards(currentFile.path);
    }
  }
  return;
}

// Extract original image name from the file ID
const getImageName = (fileId) => {
  const imageData = ea.targetView.excalidrawData.getFile(fileId);
  if (imageData?.linkParts?.original) {
    const pathParts = imageData.linkParts.original.split('/');
    const fileName = pathParts[pathParts.length - 1];
    return fileName.split('.')[0]; // Remove extension
  }
  return 'image';
};

// Function to generate current timestamp for file names (For card file names)
const getCurrentTimestamp = () => {
  const now = new Date();
  const baseTimestamp = now.getFullYear() + 
                       (now.getMonth() + 1).toString().padStart(2, '0') +
                       now.getDate().toString().padStart(2, '0') +
                       now.getHours().toString().padStart(2, '0') +
                       now.getMinutes().toString().padStart(2, '0') +
                       now.getSeconds().toString().padStart(2, '0') +
                       now.getMilliseconds().toString().padStart(3, '0');
  return baseTimestamp;
};

// Create timestamp for folder name (For folder naming)
const now = new Date();
const timestamp = now.getFullYear() + '-' +  // 使用完整年份
                 (now.getMonth() + 1).toString().padStart(2, '0') + '-' +
                 now.getDate().toString().padStart(2, '0') + ' ' +
                 now.getHours().toString().padStart(2, '0') + '.' +
                 now.getMinutes().toString().padStart(2, '0') + '.' +
                 now.getSeconds().toString().padStart(2, '0');

// Initialize or get script settings for card location
let settings = ea.getScriptSettings();

// Default settings configuration
const defaultSettings = {
  "Output Base Folder": {
    value: "",
    description: "Base folder for storing generated files. Always use forward slash '/' for paths. Example: 'Excalidraw-Image-Occlusions', 'Cards/Image-Occlusions'",
    valueset: []  // Empty array allows free text input
  },
  "Card Location": {
    value: "ask",
    description: "Where to save card files ('default' for same folder as images, or 'choose' for custom location)",
    valueset: ["ask", "default", "choose"]
  },
  "Default Card Path": {
    value: "",
    description: "Default path for card files when 'Card Location' is set to 'default'. Always use forward slash '/' for paths. Examples: 'flashcard/Anki', 'My Notes/Cards/Occlusion'. Leave empty to save with images",
    valueset: []  // Empty array allows free text input
  },
  "Default Template": {
    value: "",
    description: "Default template file path relative to template folder (e.g., 'Anki/Image Occlusion.md'). Leave empty to select template each time",
    valueset: []  // Empty array allows free text input
  },
  "Card File Prefix": {
    value: "",
    description: "Prefix for generated card files. Must be a valid filename without dots. Examples: 'anki - ', 'card ', 'io - '. Leave empty for no prefix",
    valueset: []  // Empty array allows free text input
  },
  "Card File Suffix": {
    value: "",
    description: "Suffix for generated card files (before .md). Examples: ' -card.card3' will generate 'prefix-timestamp-card.card3.md'. Leave empty for no suffix",
    valueset: []  // Empty array allows free text input
  },
  "Image Quality": {
    value: "1.5",
    description: "Export scale for image quality (e.g., 1.5). Higher values mean better quality but larger files. Must be a valid number.",
    valueset: []  // Empty array allows free text input
  },
  "Hide All, Guess One - Highlight Color": {
    value: "#ffd700",
    description: "Color used to highlight the target mask in 'Hide All, Guess One' mode (e.g., #ffd700 for gold, #ff0000 for red)",
    valueset: []  // Empty array allows free text input
  },
  "Generate Images No Matter What": {
    value: "no",
    description: "Always generate images even when template selection is cancelled (yes/no)",
    valueset: ["yes", "no"]
  }
};

// Initialize settings if they don't exist or merge with defaults
if (!settings) {
  settings = defaultSettings;
  await ea.setScriptSettings(settings);
} else {
  // Check and add any missing settings
  let needsUpdate = false;
  Object.entries(defaultSettings).forEach(([key, defaultValue]) => {
    if (!settings[key]) {
      settings[key] = defaultValue;
      needsUpdate = true;
    }
  });
  
  if (needsUpdate) {
    await ea.setScriptSettings(settings);
  }
}

// Validate and get image quality setting
const validateQuality = (quality) => {
  // Try to parse as float and check if it's a valid number
  const value = parseFloat(quality);
  return !isNaN(value) && isFinite(value) && value > 0;
};

// Get image quality with validation
const imageQuality = validateQuality(settings["Image Quality"]?.value) 
  ? settings["Image Quality"].value 
  : "1.5";  // Default to 1.5 if invalid

// Get and validate highlight color setting
const validateColor = (color) => {
  // Check if it's a valid hex color
  return /^#[0-9A-Fa-f]{6}$/.test(color);
};

// Get highlight color with validation
const highlightColor = validateColor(settings["Hide All, Guess One - Highlight Color"]?.value) 
  ? settings["Hide All, Guess One - Highlight Color"].value 
  : "#ffd700";  // Default to gold if invalid

// Function to prompt user for card file save location
const askForCardLocation = async (imageFolder) => {
  // Use the initialized settings
  const locationSetting = settings["Card Location"].value;
  const defaultPath = settings["Default Card Path"]?.value?.trim();
  
  // If setting is "default", use configured path or image folder
  if (locationSetting === "default") {
    if (defaultPath) {
      // Normalize path: replace backslashes and remove trailing slash
      const normalizedPath = defaultPath
        .replace(/\\/g, '/')
        .replace(/\/+$/, '');  // Remove trailing slashes
      
      // Create default path if it doesn't exist
      await app.vault.adapter.mkdir(normalizedPath, { recursive: true });
      return normalizedPath;
    }
    return imageFolder;
  }
  
  // If setting is "choose", skip dialog and go straight to folder selection
  if (locationSetting === "choose") {
    // Get list of all available folders for user selection
    const folders = app.vault.getAllLoadedFiles()
      .filter(f => f.children)
      .map(f => f.path)
      .sort();
    
    // Let user choose from available folders
    const selectedFolder = await utils.suggester(
      folders,
      folders,
      "Select folder for card files"
    );
    
    // Return null if user cancels folder selection
    if (selectedFolder === undefined) {
      return null;
    }
    
    return selectedFolder || imageFolder;
  }
  
  // If setting is "ask", show the choice dialog
  const choice = await utils.suggester(
    [
      defaultPath ? `Default location (${defaultPath})` : "Default location (with images)", 
      "Choose custom location"
    ],
    ["default", "custom"],
    "Where would you like to save the card files?"
  );
  
  // If user cancels (presses ESC), return null
  if (choice === undefined) {
    return null;
  }
  
  // Return default location if no choice or default selected
  if(!choice || choice === "default") {
    if (defaultPath) {
      // Normalize path: replace backslashes and remove trailing slash
      const normalizedPath = defaultPath
        .replace(/\\/g, '/')
        .replace(/\/+$/, '');  // Remove trailing slashes
      
      // Create default path if it doesn't exist
      await app.vault.adapter.mkdir(normalizedPath, { recursive: true });
      return normalizedPath;
    }
    return imageFolder;
  }
  
  // Get list of all available folders for user selection
  const folders = app.vault.getAllLoadedFiles()
    .filter(f => f.children)
    .map(f => f.path)
    .sort();
  
  // Let user choose from available folders
  const selectedFolder = await utils.suggester(
    folders,
    folders,
    "Select folder for card files"
  );
  
  // Return null if user cancels folder selection
  if (selectedFolder === undefined) {
    return null;
  }
  
  return selectedFolder || imageFolder;
};

// Function to construct image folder path using image name and timestamp
const getImageFolder = (imageName, timestamp) => {
  const baseFolder = settings["Output Base Folder"]?.value?.trim() || "Excalidraw-Image-Occlusions";
  // Normalize path and remove trailing slash
  const normalizedBase = baseFolder
    .replace(/\\/g, '/')
    .replace(/\/+$/, '');
  return `${normalizedBase}/${imageName}__${timestamp}`;
};

// Function to determine final output folder path based on settings or user choice
const getOutputFolder = async (imageName, timestamp) => {
  // Get default image folder path
  const imageFolder = getImageFolder(imageName, timestamp);
  
  // Return default path if settings specify default location
  if(settings["Card Location"].value === "default") {
    return imageFolder;
  }
  
  // Get list of all available folders for user selection
  const folders = app.vault.getAllLoadedFiles()
    .filter(f => f.children)
    .map(f => f.path)
    .sort();
  
  // Let user choose output folder
  const selectedFolder = await utils.suggester(
    folders,
    folders,
    "Select folder for card files"
  );
  
  // Return default folder if no selection made
  if(!selectedFolder) {
    return imageFolder;
  }
  
  return selectedFolder;
};

// Helper function to get current Excalidraw file path
const getCurrentFilePath = () => {
  const file = app.workspace.getActiveFile();
  return file ? file.path : '';
};

// Get current editing file name for folder naming
const getSourceFileName = () => {
  const currentFile = app.workspace.getActiveFile();
  if (!currentFile) {
    return 'image';
  }
  // Remove extension and replace special characters
  return currentFile.basename.replace(/[\\/:*?"<>|]/g, '_');
};

// Create necessary folders for storing images and cards
const imageName = getSourceFileName();
const imageFolder = getImageFolder(imageName, timestamp);
const cardFolder = await askForCardLocation(imageFolder);

// Exit if user cancelled location selection
if (cardFolder === null) {
  new Notice("Operation cancelled");
  return;
}

// Create image folder with all parent directories
await app.vault.adapter.mkdir(imageFolder, { recursive: true });

// Create card folder if different from image folder
if(cardFolder !== imageFolder) {
  await app.vault.adapter.mkdir(cardFolder, { recursive: true });
}

// Create initial batch marker file
const createBatchMarker = async (sourceFile) => {
  const content = `Source: [[${sourceFile}|find edit source]]\n\nGenerated Cards:\n`;
  const fileName = `${imageFolder}/batch-marker.md`;
  await app.vault.create(fileName, content);
  return fileName;
};

// Add card to batch marker
const addCardToBatchMarker = async (cardPath) => {
  const markerPath = `${imageFolder}/batch-marker.md`;
  const currentContent = await app.vault.read(app.vault.getAbstractFileByPath(markerPath));
  // Use full path in batch-marker
  const newContent = currentContent + `[[${cardPath}]]\n`;
  await app.vault.modify(app.vault.getAbstractFileByPath(markerPath), newContent);
};

// Create batch marker file after folders are created
const sourceFile = getCurrentFilePath();
const batchMarkerFile = await createBatchMarker(sourceFile);

// Function to convert base64 image data to binary format
const base64ToBinary = (base64) => {
  // Remove data URL prefix
  const base64Data = base64.replace(/^data:image\/png;base64,/, "");
  // Convert base64 to binary string
  const binaryString = window.atob(base64Data);
  // Convert binary string to Uint8Array
  const bytes = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  return bytes;
};

// Function to generate image with specified visible and hidden masks
const generateMaskedImage = async (visibleMasks = [], hiddenMasks = []) => {
  // Combine all selected images and masks into one array
  const allElements = [...selectedImages];
  [...visibleMasks, ...hiddenMasks].forEach(mask => {
    if (mask.type === "group") {
      allElements.push(...mask.elements);
    } else {
      allElements.push(mask);
    }
  });
  
  // Copy elements to Excalidraw's editing area
  ea.copyViewElementsToEAforEditing(allElements);
  
  // Get and cache all selected images data
  for (const img of selectedImages) {
    const imageData = ea.targetView.excalidrawData.getFile(img.fileId);
    if (imageData) {
      ea.imagesDict[img.fileId] = {
        id: img.fileId,
        dataURL: imageData.img,
        mimeType: imageData.mimeType,
        created: Date.now()
      };
    }
  }

  // Configure visibility of masks for question image
  visibleMasks.forEach(mask => {
    if (mask.type === "group") {
      // Set all elements in group to fully visible
      mask.elements.forEach(el => {
        const element = ea.getElement(el.id);
        element.opacity = 100;
      });
    } else {
      // Set single element to fully visible
      const element = ea.getElement(mask.id);
      element.opacity = 100;
    }
  });

  // Configure invisibility of masks for answer image
  hiddenMasks.forEach(mask => {
    if (mask.type === "group") {
      // Set all elements in group to invisible
      mask.elements.forEach(el => {
        const element = ea.getElement(el.id);
        element.opacity = 0;
      });
    } else {
      // Set single element to invisible
      const element = ea.getElement(mask.id);
      element.opacity = 0;
    }
  });

  // Generate PNG with specific export settings
  const dataURL = await ea.createPNGBase64(
    null,
    parseFloat(imageQuality),
    {
      exportWithDarkMode: false,
      exportWithBackground: true,
      viewBackgroundColor: "#ffffff",
      exportScale: parseFloat(imageQuality),
      quality: 100
    }
  );

  // Clear Excalidraw's editing area
  ea.clear();
  return dataURL;
};

// Function to get available Templater templates
const getTemplates = () => {
  // Check if Templater plugin is installed
  const templaterPlugin = app.plugins.plugins["templater-obsidian"];
  if (!templaterPlugin) {
    new Notice("Templater plugin is not installed");
    return null;
  }
  
  // Check if template folder is configured
  const templateFolder = templaterPlugin.settings.templates_folder;
  if (!templateFolder) {
    new Notice("Template folder is not set in Templater settings");
    return null;
  }

  // Get template folder and verify it exists
  const templates = app.vault.getAbstractFileByPath(templateFolder);
  if (!templates || !templates.children) {
    new Notice("No templates found");
    return null;
  }

  // Return only markdown files from template folder
  return templates.children.filter(f => f.extension === "md");
};

// Function to create card markdown file from template
const createMarkdownFromTemplate = async (templatePath, cardNumber, imagePath, sourceFile) => {
  const templaterPlugin = app.plugins.plugins["templater-obsidian"];
  const template = await app.vault.read(templatePath);
  
  // Convert absolute file paths to relative paths for Obsidian links
  const vaultPath = app.vault.adapter.getBasePath();
  const relativePath = {
    question: imagePath.question.replace(vaultPath, '').replace(/\\/g, '/'),
    answer: imagePath.answer.replace(vaultPath, '').replace(/\\/g, '/')
  };
  
  // Replace template placeholders with actual values
  let content = template
    .replace(/{{card_number}}/g, cardNumber)
    .replace(/{{question}}/g, relativePath.question)
    .replace(/{{answer}}/g, relativePath.answer)
    .replace(/{{editSource}}/g, sourceFile)
    .replace(/{{batchMarker}}/g, `${imageFolder}/batch-marker.md`);
  
  // Get and validate file prefix from settings
  const validatePrefix = (prefix) => {
    // Allow trailing spaces but validate the actual prefix content
    const actualPrefix = prefix.replace(/^\s+|\s+$/g, '');  // Remove leading and trailing spaces for validation only
    return !actualPrefix || /^[a-zA-Z0-9_\s-]+$/.test(actualPrefix);
  };
  
  // Get and validate file suffix from settings
  const validateSuffix = (suffix) => {
    // Allow trailing spaces but validate the actual suffix content
    const actualSuffix = suffix.replace(/^\s+|\s+$/g, '');  // Remove leading and trailing spaces for validation only
    return !actualSuffix || /^[a-zA-Z0-9_\s\-.]+$/.test(actualSuffix);  // Allow dots in suffix
  };
  
  const filePrefix = settings["Card File Prefix"]?.value || "";  // Don't trim to keep original spaces
  const validatedPrefix = validatePrefix(filePrefix) ? filePrefix : "";
  const prefixPart = validatedPrefix || "";
  
  // Get and validate file suffix from settings
  const fileSuffix = settings["Card File Suffix"]?.value || "";  // Don't trim to keep original spaces
  const validatedSuffix = validateSuffix(fileSuffix) ? fileSuffix : "";
  const suffixPart = validatedSuffix || "";
  
  // Create new card file with generated content
  const fileName = `${cardFolder}/${prefixPart}${cardNumber}${suffixPart}.md`;
  await app.vault.create(fileName, content);
  
  // Add card to batch marker after successful creation
  await addCardToBatchMarker(fileName);
};

// Function to get template file based on settings
const getTemplateFile = async (templates) => {
  // Get default template path from settings
  const defaultTemplate = settings["Default Template"]?.value?.trim();
  
  if (defaultTemplate) {
    // Try to find the default template
    const templateFile = templates.find(t => t.path.endsWith(defaultTemplate));
    if (templateFile) {
      return templateFile;
    }
  }
  
  // If no default template or not found, let user select
  return await utils.suggester(
    templates.map(t => t.basename),
    templates,
    "Select a template for the cards"
  );
};

// Begin card generation process based on selected mode
let counter = 1;
let templateFile = null;  // Move templateFile declaration to outer scope

if(mode === "hideAll") {
  // Get template selection from user for Hide All mode
  const templates = getTemplates();
  
  // Only try to get template if templates exist
  if (templates) {
    // Get template file based on settings or user selection
    templateFile = await getTemplateFile(templates);
  }

  // Check if we should proceed without template
  const generateImagesNoMatterWhat = settings["Generate Images No Matter What"]?.value === "yes";
  if (!templateFile && !generateImagesNoMatterWhat) {
    new Notice("Operation cancelled - no template selected");
    return;
  }

  // Generate cards for each mask in Hide All mode
  for(let i = 0; i < masks.length; i++) {
    // Set current mask as hidden, all others as visible
    const hiddenMasks = [masks[i]];
    const visibleMasks = masks.filter((_, index) => index !== i);
    
    // Generate unique timestamp for this card
    const fileTimestamp = getCurrentTimestamp();
    
    // Create a copy of all masks and highlight the target mask
    const questionMasks = masks.map(mask => {
      if (mask === hiddenMasks[0]) {
        // Handle group type masks
        if (mask.type === "group") {
          return {
            ...mask,
            elements: mask.elements.map(el => ({
              ...el,
              strokeWidth: 4,              
              strokeColor: highlightColor,  
              strokeStyle: "solid",        
              roughness: 0                 
            }))
          };
        }
        // Handle single element masks
        return {
          ...mask,
          strokeWidth: 4,              
          strokeColor: highlightColor,  
          strokeStyle: "solid",        
          roughness: 0                 
        };
      }
      return mask;
    });
    
    if (templateFile || generateImagesNoMatterWhat) {
      // Generate question image with all masks visible
      const questionDataURL = await generateMaskedImage(questionMasks, []);
      const questionPath = `${imageFolder}/q-${fileTimestamp}.png`;
      await app.vault.adapter.writeBinary(
        questionPath,
        base64ToBinary(questionDataURL)
      );
      
      // Generate answer image with one mask hidden and others visible
      const dataURL = await generateMaskedImage(visibleMasks, hiddenMasks);
      const imagePath = `${imageFolder}/a-${fileTimestamp}.png`;
      
      // Save answer image to disk
      await app.vault.adapter.writeBinary(
        imagePath,
        base64ToBinary(dataURL)
      );

      // Only create markdown file if template was selected
      if (templateFile) {
        const fullPaths = {
          question: app.vault.adapter.getFullPath(questionPath),
          answer: app.vault.adapter.getFullPath(imagePath)
        };
        await createMarkdownFromTemplate(
          templateFile,
          fileTimestamp,
          fullPaths,
          sourceFile
        );
      }
    }
  }
} else if(mode === "hideOne") {
  // Process Hide One, Guess One mode
  const templates = getTemplates();
  
  // Only try to get template if templates exist
  if (templates) {
    templateFile = await getTemplateFile(templates);
  }

  // Check if we should proceed without template
  const generateImagesNoMatterWhat = settings["Generate Images No Matter What"]?.value === "yes";
  if (!templateFile && !generateImagesNoMatterWhat) {
    new Notice("Operation cancelled - no template selected");
    return;
  }

  if (templateFile || generateImagesNoMatterWhat) {
    // Generate common answer image first (all masks hidden)
    const commonAnswerTimestamp = getCurrentTimestamp();
    const commonAnswerDataURL = await generateMaskedImage([], masks);
    const commonAnswerPath = `${imageFolder}/a-${commonAnswerTimestamp}.png`;
    await app.vault.adapter.writeBinary(
      commonAnswerPath,
      base64ToBinary(commonAnswerDataURL)
    );
    
    // Get full path for common answer image
    const commonAnswerFullPath = app.vault.adapter.getFullPath(commonAnswerPath);

    // Process each mask individually
    for(const mask of masks) {
      // Set current mask as visible, others as hidden for question
      const visibleMasks = masks.filter(m => m !== mask);
      const hiddenMasks = [mask];
      
      // Generate unique timestamp for this card
      const fileTimestamp = getCurrentTimestamp();
      
      // Generate question image showing only the current mask
      const questionDataURL = await generateMaskedImage([mask], visibleMasks);
      const questionPath = `${imageFolder}/q-${fileTimestamp}.png`;
      await app.vault.adapter.writeBinary(
        questionPath,
        base64ToBinary(questionDataURL)
      );
      
      // Only create markdown file if template was selected
      if (templateFile) {
        const fullPaths = {
          question: app.vault.adapter.getFullPath(questionPath),
          answer: commonAnswerFullPath
        };
        await createMarkdownFromTemplate(
          templateFile,
          fileTimestamp,
          fullPaths,
          sourceFile
        );
      }
    }
  }
} else if(mode === "deleteFiles") {
  try {
    const currentFile = app.workspace.getActiveFile();
    if (currentFile) {
      // Get all batch markers and their folders
      const batchMarkersMap = await getBatchMarkersInfo(currentFile);
      
      if (batchMarkersMap.size === 0) {
        new Notice("No files found to delete");
        return;
      }

      // ... rest of deleteFiles mode code remains the same ...
    }
  } catch (error) {
    console.error("Error during file deletion:", error);
    new Notice("Error occurred during file deletion");
  }
}

// Move completion message inside a try-catch block
try {
  if (templateFile || settings["Generate Images No Matter What"]?.value === "yes") {
    const messagePrefix = templateFile ? "Generated" : "Generated images only with";
    new Notice(`${messagePrefix} ${masks.length} sets of files in ${imageFolder}/`);
  }
} catch (error) {
  console.error("Error showing completion message:", error);
  new Notice("Operation completed with some errors");
}