mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
41 KiB
41 KiB
/*
Image Occlusion for Excalidraw
This script creates image occlusion cards similar to Anki's Image Occlusion Enhanced plugin.
Usage:
- Insert an image into Excalidraw
- Draw rectangles or ellipses over areas you want to occlude
- Select the image and all shapes you want to use as masks
- Run this script
- 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");
}