diff --git a/ea-scripts/Image Occlusion.md b/ea-scripts/Image Occlusion.md new file mode 100644 index 0000000..68536d0 --- /dev/null +++ b/ea-scripts/Image Occlusion.md @@ -0,0 +1,1147 @@ +/* +# 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. + +```javascript +*/ + +// 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> + + 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(//); + 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'; +}; + +// Create timestamp in format: YYMMDDHHmmssSSS +const now = new Date(); +const timestamp = now.getFullYear().toString().slice(-2) + + (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'); + +// Function to generate current timestamp for file names +const getCurrentTimestamp = () => { + const now = new Date(); + const baseTimestamp = now.getFullYear().toString().slice(-2) + + (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; +}; + +// 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 + } +}; + +// 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; +if(mode === "hideAll") { + // Get template selection from user for Hide All mode + const templates = getTemplates(); + if (!templates) return; + + // Get template file based on settings or user selection + const templateFile = await getTemplateFile(templates); + if (!templateFile) 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, // Thicker border + strokeColor: highlightColor, // Use configured highlight color + strokeStyle: "solid", // Solid line + roughness: 0 // Smooth border + })) + }; + } + // Handle single element masks + return { + ...mask, + strokeWidth: 4, // Thicker border + strokeColor: highlightColor, // Use configured highlight color + strokeStyle: "solid", // Solid line + roughness: 0 // Smooth border + }; + } + return mask; + }); + + // 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) + ); + + // Create markdown file with full paths to images + const fullPaths = { + question: app.vault.adapter.getFullPath(questionPath), + answer: app.vault.adapter.getFullPath(imagePath) + }; + // Generate card file from template with all necessary information + await createMarkdownFromTemplate( + templateFile, + fileTimestamp, + fullPaths, + sourceFile + ); + } +} else { + // Process Hide One, Guess One mode + const templates = getTemplates(); + if (!templates) return; + + // Get template file based on settings or user selection + const templateFile = await getTemplateFile(templates); + if (!templateFile) return; + + // 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) + ); + + // Create markdown file with paths to question and answer images + const fullPaths = { + question: app.vault.adapter.getFullPath(questionPath), + answer: commonAnswerFullPath + }; + // Generate card file using template and image paths + await createMarkdownFromTemplate( + templateFile, + fileTimestamp, + fullPaths, + sourceFile + ); + } +} + +// Display completion message with number of cards created +new Notice(`Generated ${masks.length} sets of files in ${imageFolder}/`); \ No newline at end of file diff --git a/ea-scripts/Image Occlusion.svg b/ea-scripts/Image Occlusion.svg new file mode 100644 index 0000000..c520aab --- /dev/null +++ b/ea-scripts/Image Occlusion.svg @@ -0,0 +1,20 @@ + + + + + + A + \ No newline at end of file diff --git a/ea-scripts/index-new.md b/ea-scripts/index-new.md index 259cd07..a1f6746 100644 --- a/ea-scripts/index-new.md +++ b/ea-scripts/index-new.md @@ -130,6 +130,7 @@ I would love to include your contribution in the script library. If you have a s |
|[[#Select Similar Elements]]| |
|[[#Slideshow]]| |
|[[#Split Ellipse]]| +|
|[[#Image Occlusion]]| ## Collaboration and Export **Keywords**: Sharing, Teamwork, Exporting, Distribution, Cooperative, Publish @@ -154,6 +155,7 @@ I would love to include your contribution in the script library. If you have a s |----|-----| |
|[[#Crop Vintage Mask]]| + --- # Description and Installation @@ -267,6 +269,8 @@ https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea ```
Author@zsviczian
SourceFile on GitHub
DescriptionAdds a rounded mask to the image by adding a full cover black mask and a rounded rectangle white mask. The script is also useful for adding just a black mask. In this case, run the script, then delete the white mask and add your custom white mask.
+ + ## Custom Zoom ```excalidraw-script-install https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Custom%20Zoom.md @@ -395,6 +399,12 @@ https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea ```
Author@zsviczian
SourceFile on GitHub
DescriptionThis script was discontinued in favor of ExcaliAI. Draw a UI and let GPT create the code for you.
+## Image Occlusion +```excalidraw-script-install +https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Image%20Occlusion.md +``` +
Author@TrillStones
SourceFile on GitHub
DescriptionAn Excalidraw script for creating Anki image occlusion cards in Obsidian, similar to Anki's Image Occlusion Enhanced add-on but integrated into your Obsidian workflow.
+ ## Invert colors ```excalidraw-script-install https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Invert%20colors.md diff --git a/images/scripts-image-occlusion.png b/images/scripts-image-occlusion.png new file mode 100644 index 0000000..ba7c844 Binary files /dev/null and b/images/scripts-image-occlusion.png differ