mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
1201 lines
41 KiB
Markdown
1201 lines
41 KiB
Markdown
/*
|
|
# 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<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");
|
|
}
|