+
{props.isStatic ? (
{
+ try {
+ // Check if state manager already exists
+ if (plugin.stateManagers.has(file)) {
+ return plugin.stateManagers.get(file);
+ }
+
+ // Read the file content to create a state manager
+ const content = await app.vault.read(file);
+
+ // Create a temporary view-like object for state manager creation
+ const tempView = {
+ file: file,
+ app: app,
+ plugin: plugin,
+ };
+
+ // Create the state manager
+ const stateManager = new StateManager(
+ app,
+ tempView as any,
+ content,
+ () => plugin.stateManagers.delete(file),
+ () => plugin.settings
+ );
+
+ // Register it
+ plugin.stateManagers.set(file, stateManager);
+
+ return stateManager;
+ } catch (error) {
+ console.error('Error ensuring state manager for file:', file.path, error);
+ return null;
+ }
+}
+
+/**
+ * Moves a card from the current board to a lane in an associated file
+ */
+async function moveCardToAssociatedFile(
+ sourceStateManager: StateManager,
+ targetFile: any,
+ cardItem: any, // Item type
+ sourcePath: number[], // Path type
+ targetLaneName: string // Name of the target lane (e.g., "Inbox")
+) {
+ try {
+ console.log(`Starting card move to ${targetFile.basename}/${targetLaneName}`);
+
+ // Get the card text from the source
+ const cardText = cardItem.data.titleRaw || cardItem.data.title;
+ console.log('Card text to move:', cardText);
+
+ // Read the target file content
+ const targetContent = await sourceStateManager.app.vault.read(targetFile as TFile);
+ const lines = targetContent.split('\n');
+
+ // Find the target lane (H2 header)
+ let targetLaneLineIndex = -1;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (line === `## ${targetLaneName}`) {
+ targetLaneLineIndex = i;
+ break;
+ }
+ }
+
+ if (targetLaneLineIndex === -1) {
+ console.error(`Target lane "${targetLaneName}" not found in ${targetFile.basename}`);
+ return;
+ }
+
+ console.log(`Found target lane at line ${targetLaneLineIndex}`);
+
+ // Find where to insert the card (after the lane header)
+ let insertLineIndex = targetLaneLineIndex + 1;
+
+ // Skip any empty lines after the header
+ while (insertLineIndex < lines.length && lines[insertLineIndex].trim() === '') {
+ insertLineIndex++;
+ }
+
+ // Create the new card line (maintaining the checkbox format)
+ const checkbox = cardItem.data.checked ? '[x]' : '[ ]';
+ const newCardLine = `- ${checkbox} ${cardText}`;
+
+ // Insert the card at the appropriate position
+ lines.splice(insertLineIndex, 0, newCardLine);
+
+ // Write the updated content back to the target file
+ const updatedContent = lines.join('\n');
+ await sourceStateManager.app.vault.modify(targetFile as TFile, updatedContent);
+
+ console.log(`Added card to ${targetFile.basename}/${targetLaneName}`);
+
+ // Now remove the card from the source board
+ sourceStateManager.setState((sourceBoard) => {
+ return removeEntity(sourceBoard, sourcePath);
+ });
+
+ console.log(
+ `Successfully moved card from ${sourceStateManager.file.basename} to ${targetFile.basename}/${targetLaneName}`
+ );
+ } catch (error) {
+ console.error('Error moving card to associated file:', error);
+ console.error('Error details:', error.stack);
+ }
+}
+
const illegalCharsRegEx = /[\\/:"*?<>|]+/g;
const embedRegEx = /!?\[\[([^\]]*)\.[^\]]+\]\]/g;
const wikilinkRegEx = /!?\[\[([^\]]*)\]\]/g;
@@ -39,7 +155,7 @@ export function useItemMenu({
stateManager,
}: UseItemMenuParams) {
return useCallback(
- (e: MouseEvent) => {
+ async (e: MouseEvent) => {
const coordinates = { x: e.clientX, y: e.clientY };
const hasDate = !!item.data.metadata.date;
const hasTime = !!item.data.metadata.time;
@@ -267,23 +383,122 @@ export function useItemMenu({
const addMoveToOptions = (menu: Menu) => {
const lanes = stateManager.state.children;
- if (lanes.length <= 1) return;
- for (let i = 0, len = lanes.length; i < len; i++) {
- menu.addItem((item) =>
- item
- .setIcon('lucide-square-kanban')
- .setChecked(path[0] === i)
- .setTitle(lanes[i].data.title)
- .onClick(() => {
- if (path[0] === i) return;
- stateManager.setState((boardData) => {
- return moveEntity(boardData, path, [i, 0]);
+
+ // Add current board lanes
+ if (lanes.length > 1) {
+ for (let i = 0, len = lanes.length; i < len; i++) {
+ menu.addItem((item) =>
+ item
+ .setIcon('lucide-square-kanban')
+ .setChecked(path[0] === i)
+ .setTitle(lanes[i].data.title)
+ .onClick(() => {
+ if (path[0] === i) return;
+ stateManager.setState((boardData) => {
+ return moveEntity(boardData, path, [i, 0]);
+ });
+ })
+ );
+ }
+ }
+ };
+
+ // Create separate menu items for each associated file
+ const addAssociatedFileMenus = async (mainMenu: Menu) => {
+ const associatedFiles = (stateManager.getSetting('associated-files') as string[]) || [];
+ console.log('Associated files found:', associatedFiles);
+
+ // Process each file and create menu items with pre-loaded content
+ for (const filePath of associatedFiles) {
+ const file = stateManager.app.vault.getAbstractFileByPath(filePath);
+ console.log('Processing associated file:', filePath, 'found:', !!file);
+
+ if (file && 'extension' in file && file.extension === 'md') {
+ const fileBasename = (file as any).basename;
+
+ try {
+ // Read the file content first, before creating the menu item
+ console.log('Pre-loading content for:', fileBasename);
+ const content = await stateManager.app.vault.cachedRead(file as TFile);
+ console.log('Read content from:', fileBasename, 'length:', content.length);
+
+ // Parse H2 headers to find lanes
+ const lines = content.split('\n');
+ const fileLanes: string[] = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith('## ') && !trimmed.includes('%%')) {
+ const laneTitle = trimmed.substring(3).trim();
+ if (laneTitle) {
+ fileLanes.push(laneTitle);
+ }
+ }
+ }
+
+ console.log('Pre-parsed lanes for', fileBasename + ':', fileLanes);
+
+ // Now create the menu item with the pre-loaded data
+ if (fileLanes.length > 0) {
+ mainMenu.addItem((fileMenuItem) => {
+ const fileSubmenu = (fileMenuItem as any)
+ .setIcon('lucide-file-text')
+ .setTitle(`Move to list (${fileBasename})`)
+ .setSubmenu();
+
+ console.log(
+ 'Created menu item for:',
+ fileBasename,
+ 'with',
+ fileLanes.length,
+ 'lanes'
+ );
+
+ // Add lanes to this file's submenu immediately
+ fileLanes.forEach((laneTitle) => {
+ console.log('Adding lane to menu:', laneTitle);
+ fileSubmenu.addItem((laneItem: any) =>
+ laneItem
+ .setIcon('lucide-square-kanban')
+ .setTitle(laneTitle)
+ .onClick(async () => {
+ console.log(`Moving card to ${fileBasename}/${laneTitle}`);
+ await moveCardToAssociatedFile(
+ stateManager,
+ file as TFile,
+ item,
+ path,
+ laneTitle
+ );
+ })
+ );
+ });
+ });
+ } else {
+ // No lanes found - create disabled menu item
+ console.log('No lanes found in:', fileBasename);
+ mainMenu.addItem((fileMenuItem) => {
+ fileMenuItem
+ .setIcon('lucide-alert-circle')
+ .setTitle(`Move to list (${fileBasename}) - no lanes`)
+ .setDisabled(true);
});
- })
- );
+ }
+ } catch (error) {
+ console.error('Error pre-loading file:', fileBasename, error);
+ // Create error menu item
+ mainMenu.addItem((fileMenuItem) => {
+ fileMenuItem
+ .setIcon('lucide-alert-circle')
+ .setTitle(`Move to list (${fileBasename}) - error`)
+ .setDisabled(true);
+ });
+ }
+ }
}
};
+ // Add the main "Move to list" submenu for current board
if (Platform.isPhone) {
addMoveToOptions(menu);
} else {
@@ -297,6 +512,138 @@ export function useItemMenu({
});
}
+ // Add separate "Move to list (FileName)" menu items for associated files
+ // Load associated files synchronously before showing menu
+ await addAssociatedFileMenus(menu);
+
+ // Add Copy to calendar functionality (like Move to list)
+ // Only show if the feature is enabled in settings
+ const copyToCalendarEnabled = stateManager.getSetting('enable-copy-to-calendar');
+ console.log('Copy to calendar enabled:', copyToCalendarEnabled);
+ if (copyToCalendarEnabled) {
+ const calendars = getFullCalendarDataSync(stateManager);
+ console.log('Available calendars:', calendars);
+
+ const addCopyToCalendarOptions = (menu: Menu) => {
+ if (calendars.length === 0) {
+ menu.addItem((item) => item.setTitle('No calendars found').setDisabled(true));
+ return;
+ }
+
+ // Helper function to get closest unicode circle for a color
+ function getColorCircle(color: string): string {
+ console.log(`π¨ Detecting color for: ${color}`);
+
+ const colorLower = color.toLowerCase();
+ // Map common colors to Unicode circles
+ if (colorLower.includes('red') || colorLower === '#ff0000' || colorLower === '#f00')
+ return 'π΄';
+ if (colorLower.includes('blue') || colorLower === '#0000ff' || colorLower === '#00f')
+ return 'π΅';
+ if (colorLower.includes('green') || colorLower === '#00ff00' || colorLower === '#0f0')
+ return 'π’';
+ if (colorLower.includes('yellow') || colorLower === '#ffff00' || colorLower === '#ff0')
+ return 'π‘';
+ if (
+ colorLower.includes('purple') ||
+ colorLower.includes('violet') ||
+ colorLower.includes('magenta')
+ )
+ return 'π£';
+ if (colorLower.includes('orange') || colorLower === '#ffa500') return 'π ';
+ if (colorLower.includes('brown')) return 'π€';
+ if (colorLower.includes('black') || colorLower === '#000000' || colorLower === '#000')
+ return 'β«';
+ if (colorLower.includes('white') || colorLower === '#ffffff' || colorLower === '#fff')
+ return 'βͺ';
+
+ // For hex colors, try to determine the dominant color
+ if (color.startsWith('#')) {
+ const hex = color.slice(1);
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ console.log(`π¨ RGB values: R=${r}, G=${g}, B=${b}`);
+
+ // Improved color detection logic
+ const maxVal = Math.max(r, g, b);
+ const minVal = Math.min(r, g, b);
+ const diff = maxVal - minVal;
+
+ // Check for purple/magenta (high red + blue, low green)
+ if (r > 100 && b > 100 && g < Math.min(r, b) * 0.7) {
+ console.log(`π¨ Detected purple: ${color} -> π£`);
+ return 'π£';
+ }
+
+ // Check for orange (high red, medium green, low blue)
+ if (r > g && g > b && r > 150 && g > 80 && b < 100) {
+ console.log(`π¨ Detected orange: ${color} -> π `);
+ return 'π ';
+ }
+
+ // Check for yellow (high red + green, low blue)
+ if (r > 150 && g > 150 && b < 100) {
+ console.log(`π¨ Detected yellow: ${color} -> π‘`);
+ return 'π‘';
+ }
+
+ // Primary color detection
+ if (r > g && r > b && diff > 50) {
+ console.log(`π¨ Detected red: ${color} -> π΄`);
+ return 'π΄';
+ }
+ if (g > r && g > b && diff > 50) {
+ console.log(`π¨ Detected green: ${color} -> π’`);
+ return 'π’';
+ }
+ if (b > r && b > g && diff > 50) {
+ console.log(`π¨ Detected blue: ${color} -> π΅`);
+ return 'π΅';
+ }
+ }
+
+ console.log(`π¨ Defaulting to black: ${color} -> β«`);
+ return 'β«';
+ }
+
+ for (let i = 0, len = calendars.length; i < len; i++) {
+ const calendar = calendars[i];
+ const displayName = getCalendarDisplayName(calendar.directory);
+ const colorCircle = getColorCircle(calendar.color);
+ const titleWithCircle = `${colorCircle} ${displayName}`;
+
+ menu.addItem((menuItem) =>
+ menuItem
+ .setIcon('lucide-calendar')
+ .setTitle(titleWithCircle)
+ .onClick(async () => {
+ await createCalendarEvent(stateManager, item, calendar, path, boardModifiers);
+ })
+ );
+ }
+ };
+
+ if (Platform.isPhone) {
+ // For mobile, add calendar options directly to main menu
+ addCopyToCalendarOptions(menu);
+ } else {
+ // For desktop, create submenu like "Move to list"
+ menu.addItem((menuItem) => {
+ console.log('Creating Copy to calendar submenu...');
+ const submenu = (menuItem as any)
+ .setTitle(t('Copy to calendar'))
+ .setIcon('lucide-calendar-plus')
+ .setSubmenu();
+
+ console.log('Submenu created, adding calendar options...');
+ addCopyToCalendarOptions(submenu);
+ console.log('Calendar options added to submenu');
+ });
+ }
+ }
+
menu.showAtPosition(coordinates);
},
[setEditState, item, path, boardModifiers, stateManager]
diff --git a/src/components/Item/helpers.ts b/src/components/Item/helpers.ts
index 18374b5c..f126f91e 100644
--- a/src/components/Item/helpers.ts
+++ b/src/components/Item/helpers.ts
@@ -1,5 +1,6 @@
import { FileWithPath, fromEvent } from 'file-selector';
-import { Platform, TFile, TFolder, htmlToMarkdown, moment, parseLinktext, setIcon } from 'obsidian';
+import update from 'immutability-helper';
+import { Platform, TFile, TFolder, htmlToMarkdown, moment, parseLinktext, setIcon, Notice } from 'obsidian';
import { StateManager } from 'src/StateManager';
import { Path } from 'src/dnd/types';
import { buildLinkToDailyNote } from 'src/helpers';
@@ -12,6 +13,16 @@ import { Instance } from '../Editor/flatpickr/types/instance';
import { c, escapeRegExpStr } from '../helpers';
import { Item } from '../types';
+/**
+ * Interface for Full Calendar plugin calendar sources
+ * Used for the "Copy to calendar" feature integration
+ */
+interface CalendarSource {
+ type: string; // Calendar type (typically 'local' for file-based calendars)
+ color: string; // Display color for the calendar picker UI
+ directory: string; // Target directory path for calendar events (may contain wildcards like '/*')
+}
+
export function constructDatePicker(
win: Window,
stateManager: StateManager,
@@ -611,3 +622,439 @@ export async function handleDragOrPaste(
}
}
}
+
+/**
+ * Synchronously retrieves Full Calendar plugin configuration data
+ * Supports both direct plugin access and file-based fallback for robustness
+ *
+ * @param stateManager - StateManager instance for accessing Obsidian app
+ * @returns Array of CalendarSource objects from Full Calendar plugin configuration
+ */
+export function getFullCalendarDataSync(stateManager: StateManager): CalendarSource[] {
+ try {
+
+ // Try direct access via Obsidian's plugin system first
+ if ((stateManager.app as any).plugins?.plugins?.['obsidian-full-calendar']) {
+ const fullCalendarPlugin = (stateManager.app as any).plugins.plugins['obsidian-full-calendar'];
+
+ if (fullCalendarPlugin.settings?.calendarSources) {
+ return fullCalendarPlugin.settings.calendarSources;
+ }
+ }
+
+ // Try reading the file directly via Node.js (desktop only)
+ if (Platform.isDesktopApp && (window as any).require) {
+ try {
+ const fs = (window as any).require('fs');
+ const path = (window as any).require('path');
+
+ const vaultPath = (stateManager.app.vault.adapter as any).path ||
+ (stateManager.app.vault.adapter as any).basePath;
+ const fullCalendarDataPath = path.join(vaultPath, '.obsidian', 'plugins', 'obsidian-full-calendar', 'data.json');
+
+ if (fs.existsSync(fullCalendarDataPath)) {
+ const content = fs.readFileSync(fullCalendarDataPath, 'utf8');
+ const data = JSON.parse(content);
+ return data.calendarSources || [];
+ }
+ } catch (fsError) {
+ // Silent fallback
+ }
+ }
+ return [];
+
+ } catch (error) {
+ console.error('Error reading Full Calendar data:', error);
+ return [];
+ }
+}
+
+/**
+ * Asynchronously retrieves Full Calendar plugin configuration data
+ * Identical to getFullCalendarDataSync but returns a Promise for async contexts
+ *
+ * @param stateManager - StateManager instance for accessing Obsidian app
+ * @returns Promise resolving to array of CalendarSource objects
+ */
+export async function getFullCalendarData(stateManager: StateManager): Promise {
+ try {
+ console.log('Looking for Full Calendar data...');
+
+ // Try direct access via Obsidian's plugin system
+ if ((stateManager.app as any).plugins?.plugins?.['obsidian-full-calendar']) {
+ const fullCalendarPlugin = (stateManager.app as any).plugins.plugins['obsidian-full-calendar'];
+ console.log('Found Full Calendar plugin:', fullCalendarPlugin);
+
+ if (fullCalendarPlugin.settings?.calendarSources) {
+ console.log('Found calendar sources from plugin settings:', fullCalendarPlugin.settings.calendarSources);
+ return fullCalendarPlugin.settings.calendarSources;
+ }
+ }
+
+ // Try reading the file directly via Node.js (desktop only)
+ if (Platform.isDesktopApp && (window as any).require) {
+ try {
+ const fs = (window as any).require('fs');
+ const path = (window as any).require('path');
+
+ const vaultPath = (stateManager.app.vault.adapter as any).path ||
+ (stateManager.app.vault.adapter as any).basePath;
+ const fullCalendarDataPath = path.join(vaultPath, '.obsidian', 'plugins', 'obsidian-full-calendar', 'data.json');
+
+ console.log('Trying file path:', fullCalendarDataPath);
+
+ if (fs.existsSync(fullCalendarDataPath)) {
+ const content = fs.readFileSync(fullCalendarDataPath, 'utf8');
+ const data = JSON.parse(content);
+ console.log('Found Full Calendar data from file:', data);
+ return data.calendarSources || [];
+ } else {
+ console.log('File does not exist at:', fullCalendarDataPath);
+ }
+ } catch (fsError) {
+ console.log('Direct file access failed:', fsError);
+ }
+ }
+
+ // Fallback: hardcoded test data (remove this once it works)
+ console.log('Using fallback test data');
+ return [
+ { type: 'local', color: '#ff0000', directory: 'Test Calendar 1' },
+ { type: 'local', color: '#00ff00', directory: 'Test Calendar 2' }
+ ];
+
+ } catch (error) {
+ console.error('Error reading Full Calendar data:', error);
+ return [];
+ }
+}
+
+/**
+ * Extracts a user-friendly display name from a calendar directory path
+ * Handles special characters and wildcard patterns appropriately
+ *
+ * @param directory - Full directory path from calendar configuration
+ * @returns User-friendly display name for the calendar picker UI
+ */
+export function getCalendarDisplayName(directory: string): string {
+ // Extract the leaf directory name from the path
+ const parts = directory.split('/');
+ const leafName = parts[parts.length - 1];
+
+ // Handle special cases and wildcards
+ if (leafName === '*' || leafName === '#' || leafName === '!' ||
+ leafName === '%' || leafName === '=' || leafName === '$') {
+ return leafName;
+ }
+
+ return leafName || directory;
+}
+
+function sanitizeFileName(name: string, maxLength: number = 30): string {
+ // Remove illegal file name characters and limit length
+ const illegalCharsRegEx = /[\\/:"*?<>|]+/g;
+ const embedRegEx = /!?\[\[([^\]]*)\.[^\]]+\]\]/g;
+ const wikilinkRegEx = /!?\[\[([^\]]*)\]\]/g;
+ const mdLinkRegEx = /!?\[([^\]]*)\]\([^)]*\)/g;
+ const tagRegEx = /#([^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~[\]\\\s\n\r]+)/g;
+ const condenceWhiteSpaceRE = /\s+/g;
+
+ return name
+ .replace(embedRegEx, '$1')
+ .replace(wikilinkRegEx, '$1')
+ .replace(mdLinkRegEx, '$1')
+ .replace(tagRegEx, '$1')
+ .replace(illegalCharsRegEx, ' ')
+ .trim()
+ .replace(condenceWhiteSpaceRE, ' ')
+ .substring(0, maxLength)
+ .trim();
+}
+
+export function constructCalendarPicker(
+ win: Window,
+ stateManager: StateManager,
+ coordinates: { x: number; y: number },
+ onSelect: (calendar: CalendarSource) => void,
+ item: Item
+) {
+ const pickerClassName = c('calendar-picker');
+
+ win.document.body.createDiv({ cls: `${pickerClassName} ${c('ignore-click-outside')}` }, async (div) => {
+ const calendars = await getFullCalendarData(stateManager);
+
+ if (calendars.length === 0) {
+ div.createDiv({ cls: c('calendar-picker-empty'), text: 'No calendars found in Full Calendar plugin' });
+ }
+
+ const clickHandler = (e: MouseEvent) => {
+ if (
+ e.target instanceof (e.view as Window & typeof globalThis).HTMLElement &&
+ e.target.hasClass(c('calendar-picker-item'))
+ ) {
+ const calendarIndex = parseInt(e.target.dataset.calendarIndex || '0', 10);
+ if (calendars[calendarIndex]) {
+ onSelect(calendars[calendarIndex]);
+ selfDestruct();
+ }
+ }
+ };
+
+ const clickOutsideHandler = (e: MouseEvent) => {
+ if (
+ e.target instanceof (e.view as Window & typeof globalThis).HTMLElement &&
+ e.target.closest(`.${pickerClassName}`) === null
+ ) {
+ selfDestruct();
+ }
+ };
+
+ const escHandler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ selfDestruct();
+ }
+ };
+
+ const selfDestruct = () => {
+ div.remove();
+ div.removeEventListener('click', clickHandler);
+ win.document.body.removeEventListener('click', clickOutsideHandler);
+ win.document.removeEventListener('keydown', escHandler);
+ };
+
+ div.style.left = `${coordinates.x || 0}px`;
+ div.style.top = `${coordinates.y || 0}px`;
+
+ calendars.forEach((calendar, index) => {
+ div.createDiv(
+ {
+ cls: c('calendar-picker-item'),
+ },
+ (item) => {
+ // Add color circle
+ item.createEl('span', { cls: c('calendar-color-circle') }, (circle) => {
+ circle.style.backgroundColor = calendar.color;
+ circle.style.width = '12px';
+ circle.style.height = '12px';
+ circle.style.borderRadius = '50%';
+ circle.style.display = 'inline-block';
+ circle.style.marginRight = '8px';
+ });
+
+ // Add calendar name
+ item.createEl('span', {
+ cls: c('calendar-name'),
+ text: getCalendarDisplayName(calendar.directory)
+ });
+
+ item.dataset.calendarIndex = index.toString();
+ }
+ );
+ });
+
+ div.win.setTimeout(() => {
+ const height = div.clientHeight;
+ const width = div.clientWidth;
+
+ if (coordinates.y + height > win.innerHeight) {
+ div.style.top = `${(coordinates.y || 0) - height}px`;
+ }
+
+ if (coordinates.x + width > win.innerWidth) {
+ div.style.left = `${(coordinates.x || 0) - width}px`;
+ }
+
+ div.addEventListener('click', clickHandler);
+ win.document.body.addEventListener('click', clickOutsideHandler);
+ win.document.addEventListener('keydown', escHandler);
+ });
+ });
+}
+
+/**
+ * Creates a calendar event file from a Kanban card
+ *
+ * Integrates with Full Calendar plugin's "Full note" mode by creating markdown files
+ * with appropriate frontmatter. Events are created as all-day events on the current
+ * date and can be easily moved to specific times within Full Calendar.
+ *
+ * When copying to calendar, also adds a hashtag matching the calendar name to the card
+ * if it doesn't already have one, enabling automatic color association.
+ *
+ * The calendar event filename is created without hashtags for clean organization,
+ * while the original card retains its hashtags for color association.
+ *
+ * Handles edge cases including:
+ * - Directories with special characters (like '*')
+ * - Wildcard patterns vs literal directory names
+ * - File name sanitization and collision detection
+ * - Hashtag removal from filenames for cleaner calendar organization
+ * - Robust error handling with user-friendly notifications
+ *
+ * @param stateManager - StateManager instance for vault operations
+ * @param item - Kanban card item to copy to calendar
+ * @param calendar - Target calendar source configuration
+ * @returns Promise - true if event was created successfully, false otherwise
+ */
+export async function createCalendarEvent(
+ stateManager: StateManager,
+ item: Item,
+ calendar: CalendarSource,
+ path: Path,
+ boardModifiers: any
+): Promise {
+ try {
+ const cardTitle = item.data.titleRaw.split('\n')[0].trim();
+
+ // Remove hashtags from the title before creating the calendar event filename
+ // This ensures calendar event files have clean names without hashtag clutter
+ const titleWithoutHashtags = cardTitle
+ .replace(/#[^\s#]+/g, '') // Remove hashtags completely
+ .replace(/\s+/g, ' ') // Normalize multiple spaces to single space
+ .trim(); // Remove leading/trailing whitespace
+ const sanitizedTitle = sanitizeFileName(titleWithoutHashtags);
+
+ const today = moment().format('YYYY-MM-DD');
+ const tomorrow = moment().add(1, 'day').format('YYYY-MM-DD');
+
+ const fileName = `${today} ${sanitizedTitle}.md`;
+ const fileContent = `---
+title: ${sanitizedTitle}
+allDay: true
+date: ${today}
+endDate: ${tomorrow}
+completed: null
+---
+`;
+
+ // Get the target directory from the calendar configuration
+ let targetDirectory = calendar.directory;
+
+ // Handle wildcard directories vs literal directories containing '*'
+ // This addresses the edge case where directories are literally named with '*' characters
+ // versus using '/*' as a wildcard pattern in Full Calendar configuration
+ if (targetDirectory.endsWith('/*')) {
+ // Check if a directory with the literal name (including /*) exists first
+ const literalDirectory = stateManager.app.vault.getAbstractFileByPath(targetDirectory);
+ if (!literalDirectory) {
+ // If literal directory doesn't exist, treat /* as wildcard and remove it
+ // This supports the standard Full Calendar wildcard pattern usage
+ targetDirectory = targetDirectory.slice(0, -2);
+ }
+ // If literal directory exists, keep the full path including /*
+ // This supports edge cases where directories are literally named with '*'
+ }
+
+ // Normalize the directory path to handle special characters properly
+ // This handles cases where the directory name contains special chars like '*'
+ targetDirectory = targetDirectory.replace(/[\\\/]+/g, '/').replace(/^\/+|\/+$/g, '');
+
+ // Ensure the directory exists using proper path handling
+ let targetFolder = null;
+ try {
+ targetFolder = stateManager.app.vault.getAbstractFileByPath(targetDirectory);
+ } catch (error) {
+ console.log('Error getting folder, will attempt to create:', error);
+ }
+
+ if (!targetFolder) {
+ try {
+ await stateManager.app.vault.createFolder(targetDirectory);
+ } catch (folderError) {
+ console.error('Error creating folder:', folderError);
+ // If folder creation fails, try to normalize the path further
+ const normalizedPath = targetDirectory.replace(/[*?"<>|:]/g, '_');
+ console.log(`Attempting to create normalized folder: ${normalizedPath}`);
+ try {
+ await stateManager.app.vault.createFolder(normalizedPath);
+ targetDirectory = normalizedPath;
+ } catch (normalizedError) {
+ throw new Error(`Could not create calendar directory: ${targetDirectory}. ${normalizedError.message}`);
+ }
+ }
+ }
+
+ const fullPath = `${targetDirectory}/${fileName}`;
+
+ // Check if file already exists
+ let existingFile = null;
+ try {
+ existingFile = stateManager.app.vault.getAbstractFileByPath(fullPath);
+ } catch (error) {
+ console.log('Error checking existing file, proceeding with creation:', error);
+ }
+
+ if (existingFile) {
+ // Show notification that file already exists
+ new Notice(`File already exists: ${fileName}`);
+ return false;
+ }
+
+ // Create the file
+ try {
+ await stateManager.app.vault.create(fullPath, fileContent);
+ new Notice(`Created calendar event: ${fileName}`);
+
+ // Add calendar hashtag to the card if it doesn't already have one
+ await addCalendarHashtagToCard(stateManager, item, calendar, path, boardModifiers);
+
+ return true;
+ } catch (fileError) {
+ console.error('Error creating file:', fileError);
+ throw new Error(`Could not create calendar file: ${fileName}. ${fileError.message}`);
+ }
+
+ } catch (error) {
+ console.error('Error creating calendar event:', error);
+ new Notice(`Error creating calendar event: ${error.message}`);
+ return false;
+ }
+}
+
+/**
+ * Adds a hashtag matching the calendar name to the card if it doesn't already have one
+ */
+async function addCalendarHashtagToCard(
+ stateManager: StateManager,
+ item: Item,
+ calendar: CalendarSource,
+ path: Path,
+ boardModifiers: any
+) {
+ try {
+ const calendarName = getCalendarDisplayName(calendar.directory);
+ const cardContent = item.data.titleRaw.trim();
+
+ // Check if card already has a hashtag matching any calendar name
+ const hashtagRegex = /#([^\s#]+)/g;
+ const existingHashtags: string[] = [];
+ let match;
+
+ while ((match = hashtagRegex.exec(cardContent)) !== null) {
+ existingHashtags.push(match[1]);
+ }
+
+ // Check if any existing hashtag matches the current calendar name
+ const hasMatchingHashtag = existingHashtags.some(
+ hashtag => hashtag.toLowerCase() === calendarName.toLowerCase()
+ );
+
+ if (!hasMatchingHashtag) {
+ // Add the calendar hashtag to the end of the card content
+ const calendarHashtag = calendarName.replace(/\s+/g, ''); // Remove spaces for hashtag
+ const updatedContent = `${cardContent} #${calendarHashtag}`;
+
+ // Update the item using the board modifiers (same pattern as other updates)
+ const updatedItem = stateManager.updateItemContent(item, updatedContent);
+ boardModifiers.updateItem(path, updatedItem);
+
+ console.log(`π Added hashtag #${calendarHashtag} to card`);
+ } else {
+ console.log(`β
Card already has matching calendar hashtag`);
+ }
+
+ } catch (error) {
+ console.error('Error adding calendar hashtag to card:', error);
+ // Don't throw here - the calendar event was created successfully
+ }
+}
diff --git a/src/components/helpers.ts b/src/components/helpers.ts
index fb354b2d..da3ce877 100644
--- a/src/components/helpers.ts
+++ b/src/components/helpers.ts
@@ -13,7 +13,8 @@ import {
} from 'src/parsers/helpers/inlineMetadata';
import { SearchContextProps } from './context';
-import { Board, DataKey, DateColor, Item, Lane, PageData, TagColor } from './types';
+import { Board, CardColor, DataKey, DateColor, Item, Lane, PageData, TagColor } from './types';
+import { getFullCalendarDataSync, getCalendarDisplayName } from './Item/helpers';
export const baseClassName = 'kanban-plugin';
@@ -242,6 +243,82 @@ export function useGetTagColorFn(stateManager: StateManager): (tag: string) => T
return useMemo(() => getTagColorFn(tagColors), [tagColors]);
}
+/**
+ * Creates a function to get card colors based on hashtags that match calendar names
+ */
+export function getCardColorFn(stateManager: StateManager) {
+ return (cardId: string, cardContent?: string): CardColor | null => {
+ if (!cardContent) return null;
+
+ // Extract hashtags from card content
+ const hashtagRegex = /#([^\s#]+)/g;
+ const hashtags: string[] = [];
+ let match;
+
+ while ((match = hashtagRegex.exec(cardContent)) !== null) {
+ hashtags.push(match[1]);
+ }
+
+ if (hashtags.length === 0) return null;
+
+ // Get calendar sources from Full Calendar plugin
+ const calendars = getFullCalendarDataSync(stateManager);
+ if (calendars.length === 0) return null;
+
+ // Find first hashtag that matches a calendar name
+ for (const hashtag of hashtags) {
+ for (const calendar of calendars) {
+ const calendarName = getCalendarDisplayName(calendar.directory);
+
+ // Check if hashtag matches calendar name (case-insensitive)
+ if (hashtag.toLowerCase() === calendarName.toLowerCase()) {
+ // Calculate appropriate text color for contrast
+ const backgroundColor = calendar.color;
+ let textColor = '#000000'; // Default to black
+
+ // Handle hex colors
+ if (backgroundColor.startsWith('#')) {
+ const hex = backgroundColor.slice(1);
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ // Use simple perceived brightness calculation (similar to what Full Calendar likely uses)
+ const brightness = (r * 0.299 + g * 0.587 + b * 0.114);
+
+ // Higher threshold (more likely to use black text)
+ textColor = brightness > 140 ? '#000000' : '#ffffff';
+ } else {
+ // Handle rgb/rgba colors
+ const color = backgroundColor.replace(/rgba?\(|\s+|\)/g, '').split(',').map(Number);
+ if (color.length >= 3) {
+ const [r, g, b] = color;
+ const brightness = (r * 0.299 + g * 0.587 + b * 0.114);
+ textColor = brightness > 140 ? '#000000' : '#ffffff';
+ }
+ }
+
+ return {
+ cardId,
+ cardContent,
+ backgroundColor: calendar.color,
+ color: textColor,
+ calendarName,
+ };
+ }
+ }
+ }
+
+ return null;
+ };
+}
+
+export function useGetCardColorFn(stateManager: StateManager): (cardId: string, cardContent?: string) => CardColor | null {
+ return useMemo(() => getCardColorFn(stateManager), [stateManager]);
+}
+
+
+
export function getDateColorFn(dateColors: DateColor[]) {
const orders = (dateColors || []).map<[moment.Moment | 'today' | 'before' | 'after', DateColor]>(
(c) => {
diff --git a/src/components/types.ts b/src/components/types.ts
index 15ca1864..e5dc4ed5 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -35,6 +35,14 @@ export interface TagColor {
backgroundColor: string;
}
+export interface CardColor {
+ cardId: string;
+ cardContent: string; // Store card content for matching across ID changes
+ color: string;
+ backgroundColor: string;
+ calendarName?: string;
+}
+
export interface TagSort {
tag: string;
}
diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts
index 70f6710e..33317c33 100644
--- a/src/lang/locale/en.ts
+++ b/src/lang/locale/en.ts
@@ -130,7 +130,7 @@ const en = {
'This will be used to separate the archived date/time from the title':
'This will be used to separate the archived date/time from the title',
'Archive date/time format': 'Archive date/time format',
- 'Kanban Plugin': 'Kanban Plugin',
+ 'Kanban Plugin': 'Kanban Plus',
'Tag click action': 'Tag click action',
'Search Kanban Board': 'Search Kanban Board',
'Search Obsidian Vault': 'Search Obsidian Vault',
@@ -142,6 +142,14 @@ const en = {
'Inline Metadata': 'Inline Metadata',
'Display metadata for the first note linked within a card. Specify which metadata keys to display below. An optional label can be provided, and labels can be hidden altogether.':
'Display metadata for the first note linked within a card. Specify which metadata keys to display below. An optional label can be provided, and labels can be hidden altogether.',
+ 'File Format': 'File Format',
+ 'Place board settings at beginning': 'Place board settings at beginning',
+ 'When toggled, board-specific settings will be placed at the beginning of the file instead of at the end. This can make it easier to quickly edit board settings in markdown mode.':
+ 'When toggled, board-specific settings will be placed at the beginning of the file instead of at the end. This can make it easier to quickly edit board settings in markdown mode.',
+ Integrations: 'Integrations',
+ 'Enable Copy to Calendar': 'Enable Copy to Calendar',
+ 'Enables the "Copy to calendar" feature in card context menus. Integrates with the Full Calendar plugin\'s "Full note" mode. Requires Full Calendar plugin to be installed and configured.\n\nConfiguration file location: .obsidian/plugins/obsidian-full-calendar/data.json':
+ 'Enables the "Copy to calendar" feature in card context menus. Integrates with the Full Calendar plugin\'s "Full note" mode. Requires Full Calendar plugin to be installed and configured.\n\nConfiguration file location: .obsidian/plugins/obsidian-full-calendar/data.json',
'Board Header Buttons': 'Board Header Buttons',
'Calendar: first day of week': 'Calendar: first day of week',
'Override which day is used as the start of the week':
@@ -236,6 +244,7 @@ const en = {
'Move to top': 'Move to top',
'Move to bottom': 'Move to bottom',
'Move to list': 'Move to list',
+ 'Copy to calendar': 'Copy to calendar',
// components/Lane/LaneForm.tsx
'Enter list title...': 'Enter list title...',
@@ -274,6 +283,12 @@ const en = {
// components/Editor/MarkdownEditor.tsx
Submit: 'Submit',
+ 'Associated Files': 'Associated Files',
+ 'Link this board to other Kanban files to enable moving cards between them':
+ 'Link this board to other Kanban files to enable moving cards between them',
+ 'Add associated file': 'Add associated file',
+ 'Remove file': 'Remove file',
+ 'Move to file': 'Move to file',
};
export type Lang = typeof en;
diff --git a/src/parsers/List.ts b/src/parsers/List.ts
index 43e37f37..8ffbd970 100644
--- a/src/parsers/List.ts
+++ b/src/parsers/List.ts
@@ -41,7 +41,7 @@ export class ListFormat implements BaseFormat {
}
boardToMd(board: Board) {
- return boardToMd(board);
+ return boardToMd(board, this.stateManager);
}
mdToBoard(md: string) {
diff --git a/src/parsers/formats/list.ts b/src/parsers/formats/list.ts
index 75090048..4d112983 100644
--- a/src/parsers/formats/list.ts
+++ b/src/parsers/formats/list.ts
@@ -440,12 +440,22 @@ function archiveToMd(archive: Item[]) {
return '';
}
-export function boardToMd(board: Board) {
+export function boardToMd(board: Board, stateManager?: StateManager) {
const lanes = board.children.reduce((md, lane) => {
return md + laneToMd(lane);
}, '');
const frontmatter = ['---', '', stringifyYaml(board.data.frontmatter), '---', '', ''].join('\n');
-
- return frontmatter + lanes + archiveToMd(board.data.archive) + settingsToCodeblock(board);
+ const settingsBlock = settingsToCodeblock(board);
+
+ // Check if settings should be placed at the beginning
+ const placeSettingsAtBeginning = stateManager?.getSetting('place-settings-at-beginning');
+
+ if (placeSettingsAtBeginning) {
+ // Place settings at the beginning (after frontmatter)
+ return frontmatter + settingsBlock + '\n\n' + lanes + archiveToMd(board.data.archive);
+ } else {
+ // Default behavior: place settings at the end
+ return frontmatter + lanes + archiveToMd(board.data.archive) + settingsBlock;
+ }
}
diff --git a/src/parsers/parseMarkdown.ts b/src/parsers/parseMarkdown.ts
index a4336a67..54668964 100644
--- a/src/parsers/parseMarkdown.ts
+++ b/src/parsers/parseMarkdown.ts
@@ -62,6 +62,50 @@ function extractSettingsFooter(md: string) {
}
}
+/**
+ * Extracts kanban settings from the beginning of the markdown file
+ * Looks for %% kanban:settings blocks at the start (after frontmatter)
+ */
+function extractSettingsHeader(md: string) {
+ // Skip frontmatter if present
+ let startPos = 0;
+ if (md.startsWith('---')) {
+ const frontmatterEnd = md.indexOf('\n---', 3);
+ if (frontmatterEnd !== -1) {
+ startPos = frontmatterEnd + 4;
+ }
+ }
+
+ // Look for kanban:settings block from the beginning
+ const searchText = md.slice(startPos);
+ const settingsStart = searchText.indexOf('%% kanban:settings');
+
+ if (settingsStart === -1) {
+ return {};
+ }
+
+ // Find the code block start (```)
+ const codeBlockStart = searchText.indexOf('```', settingsStart);
+ if (codeBlockStart === -1) {
+ return {};
+ }
+
+ // Find the code block end
+ const codeBlockEnd = searchText.indexOf('```', codeBlockStart + 3);
+ if (codeBlockEnd === -1) {
+ return {};
+ }
+
+ // Extract and parse the JSON settings
+ const settingsJson = searchText.slice(codeBlockStart + 3, codeBlockEnd).trim();
+ try {
+ return JSON.parse(settingsJson);
+ } catch (e) {
+ console.error('Error parsing kanban settings from header:', e);
+ return {};
+ }
+}
+
function getExtensions(stateManager: StateManager) {
return [
gfmTaskListItem,
@@ -166,7 +210,13 @@ function getMdastExtensions(stateManager: StateManager) {
export function parseMarkdown(stateManager: StateManager, md: string) {
const mdFrontmatter = extractFrontmatter(md);
- const mdSettings = extractSettingsFooter(md);
+
+ // Try to extract settings from both header and footer
+ // Header takes precedence if both exist
+ const mdSettingsHeader = extractSettingsHeader(md);
+ const mdSettingsFooter = extractSettingsFooter(md);
+ const mdSettings = Object.keys(mdSettingsHeader).length > 0 ? mdSettingsHeader : mdSettingsFooter;
+
const settings = { ...mdSettings };
const fileFrontmatter: Record = {};
diff --git a/src/styles.less b/src/styles.less
index 2d351f68..7e63338e 100644
--- a/src/styles.less
+++ b/src/styles.less
@@ -701,6 +701,39 @@ button.kanban-plugin__new-item-button {
background: var(--background-primary);
}
+/* Calendar color styling for cards */
+.kanban-plugin__item.has-calendar-color {
+ .kanban-plugin__item-content-wrapper {
+ background: var(--card-background-color) !important;
+ color: var(--card-color) !important;
+ }
+
+ .kanban-plugin__item-title-wrapper {
+ background: var(--card-background-color) !important;
+ color: var(--card-color) !important;
+ }
+
+ /* Ensure text inside is also properly colored */
+ .kanban-plugin__item-title {
+ color: var(--card-color) !important;
+ }
+
+ .kanban-plugin__item-markdown {
+ color: var(--card-color) !important;
+ }
+
+ /* Maintain proper text colors for links and other elements */
+ .kanban-plugin__item-markdown a {
+ color: var(--card-color) !important;
+ opacity: 0.8;
+ text-decoration: underline;
+ }
+
+ .kanban-plugin__item-markdown a:hover {
+ opacity: 1;
+ }
+}
+
.kanban-plugin__item-title-wrapper {
background: var(--background-primary);
display: flex;
@@ -1477,6 +1510,92 @@ button.kanban-plugin__cancel-action-button {
background: var(--background-secondary);
}
+.kanban-plugin__calendar-picker {
+ position: absolute;
+ max-height: 250px;
+ overflow: auto;
+ border-radius: 4px;
+ border: 1px solid var(--background-modifier-border);
+ box-shadow: 0 2px 8px var(--background-modifier-box-shadow);
+ background: var(--background-primary);
+ color: var(--text-normal);
+ font-size: 14px;
+ z-index: var(--layer-menu);
+}
+
+.kanban-plugin__calendar-picker-empty {
+ padding: 12px;
+ color: var(--text-faint);
+ text-align: center;
+}
+
+.kanban-plugin__calendar-picker-item {
+ display: flex;
+ align-items: center;
+ cursor: var(--cursor);
+ line-height: 1;
+ padding-block: 8px;
+ padding-inline: 12px;
+
+ &:hover {
+ background: var(--background-secondary);
+ }
+}
+
+.kanban-plugin__calendar-color-circle {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ border: 1px solid var(--background-modifier-border);
+}
+
+.kanban-plugin__calendar-picker {
+ background-color: var(--background-primary);
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 6px;
+ box-shadow: var(--shadow-s);
+ padding: 8px;
+ max-width: 250px;
+ z-index: var(--layer-popover);
+}
+
+.kanban-plugin__calendar-picker-item {
+ display: flex;
+ align-items: center;
+ padding: 6px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ gap: 8px;
+
+ &:hover {
+ background-color: var(--background-modifier-hover);
+ }
+}
+
+.kanban-plugin__calendar-color-circle {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ border: 1px solid rgba(var(--mono-rgb-100), 0.1);
+}
+
+.kanban-plugin__calendar-name {
+ font-size: 13px;
+ color: var(--text-normal);
+ flex-grow: 1;
+}
+
+.kanban-plugin__calendar-picker-empty {
+ padding: 8px;
+ color: var(--text-muted);
+ font-size: 12px;
+ text-align: center;
+}
+
.kanban-plugin mark {
background-color: var(--text-highlight-bg);
}
@@ -1782,3 +1901,47 @@ body:not(.native-scrollbars) .kanban-plugin__scroll-container::-webkit-scrollbar
background-color: var(--date-background-color, rgba(var(--mono-rgb-100), 0.05));
}
}
+
+// File selection modal styles
+.file-selection-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin-top: 10px;
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 6px;
+}
+
+.file-selection-item {
+ padding: 8px 12px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--background-modifier-border);
+
+ &:hover {
+ background-color: var(--background-modifier-hover);
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+// Associated files settings styles
+.associated-file-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px;
+ background-color: var(--background-secondary);
+ border-radius: 6px;
+ margin-bottom: 8px;
+}
+
+.associated-file-name {
+ font-family: var(--font-monospace);
+ font-size: 0.9em;
+}
+
+.associated-file-remove {
+ padding: 4px 8px;
+ font-size: 0.8em;
+}