diff --git a/CALENDAR_COLOR_FEATURE.md b/CALENDAR_COLOR_FEATURE.md new file mode 100644 index 00000000..dcf9b9d6 --- /dev/null +++ b/CALENDAR_COLOR_FEATURE.md @@ -0,0 +1,123 @@ +# Calendar Color Feature - Hashtag-Based Visual Feedback + +## Overview + +This enhancement adds visual feedback to cards based on hashtags that match calendar names from the Full Calendar plugin. Cards automatically display calendar colors when they contain hashtags that correspond to configured calendar names, creating a seamless visual connection between task planning and calendar scheduling. + +## How It Works + +### Hashtag-Based Color Association + +1. **Hashtag Detection**: System scans card content for hashtags (e.g., `#Work`, `#Personal`) +2. **Calendar Matching**: First hashtag that matches a calendar name determines the card color +3. **Color Application**: Card background and text colors automatically update to match the associated calendar +4. **Smart Contrast**: Text color (black/white) is automatically calculated for optimal readability + +### Copy to Calendar Enhancement + +When copying a card to a calendar: + +1. **Event Creation**: Calendar event is created in the specified directory +2. **Hashtag Addition**: If the card doesn't already have a hashtag matching the calendar name, it's automatically added +3. **Instant Visual Feedback**: Card immediately displays the calendar's color scheme +4. **Persistent Association**: The hashtag creates a permanent visual connection between the card and calendar + +### Smart Text Color Calculation + +The system automatically calculates the best text color (black or white) based on the background color brightness, ensuring optimal readability regardless of the calendar color. + +## Technical Implementation + +### Hashtag-Based Color Resolution + +Colors are determined dynamically by parsing hashtags and matching them to Full Calendar configuration: + +```typescript +// Example card content +"Complete project deliverables #Work #HighPriority" + +// System finds #Work matches "Work" calendar (case-insensitive) +// Applies calendar color: #ff6b6b with contrasting text color +``` + +### Real-Time Color Lookup Process + +1. **Content Parsing**: Extract all hashtags from card content using regex: `/#([^\s#]+)/g` +2. **Calendar Matching**: Compare hashtags to calendar names from Full Calendar `data.json` +3. **Color Resolution**: First matching hashtag determines the color scheme +4. **Style Application**: Colors applied via CSS custom properties for theme integration + +### Dynamic Integration with Full Calendar + +- **Configuration Source**: Reads directly from `.obsidian/plugins/obsidian-full-calendar/data.json` +- **Live Updates**: Changes to Full Calendar settings are reflected immediately +- **No Storage Overhead**: No persistent color data stored in board settings +- **Calendar Synchronization**: Visual state always matches current calendar configuration + +### CSS Variables + +The system uses CSS custom properties for dynamic color application: + +```css +.kanban-plugin__item.has-calendar-color { + --card-background-color: var(--calendar-color); + --card-color: var(--calendar-text-color); +} +``` + +### Automatic Hashtag Addition + +When a card is copied to a calendar: +1. Check if card already has a hashtag matching the calendar name +2. If not, append the calendar name as a hashtag (e.g., `#Work`) +3. Text contrast is calculated for optimal readability +4. UI immediately reflects the new color based on the hashtag + +## User Experience Benefits + +1. **Automatic Color Association**: Cards display calendar colors based on their hashtags +2. **Zero Configuration**: No setup required - colors are determined by existing Full Calendar settings +3. **Persistent Visual Cues**: Hashtags provide permanent visual connection between cards and calendars +4. **Dynamic Updates**: Colors automatically update when Full Calendar settings change +5. **Intuitive Tagging**: Natural hashtag workflow integrates seamlessly with calendar organization +6. **Accessibility**: Automatic contrast calculation ensures text remains readable + +## Backward Compatibility + +- Cards without hashtags remain unchanged +- Existing color systems (tag colors, date colors) continue to work normally +- Feature is purely additive - no existing functionality is modified +- No changes to board settings format - hashtags are stored in card content only + +## Usage Examples + +### Automatic Color Display +```markdown +- [ ] Meeting with client #Work +- [ ] Doctor appointment #Personal +- [ ] Team review #Work +``` +Cards automatically display colors based on `#Work` and `#Personal` hashtags matching calendar names. + +### Copy to Calendar Workflow +1. Create a card: `"Complete project proposal"` +2. Right-click β†’ "Copy to Calendar" +3. Select "Work Calendar" +4. Card content becomes: `"Complete project proposal #Work"` +5. Card immediately displays Work calendar's color scheme + +### Manual Hashtag Assignment +Add hashtags to any card to instantly apply calendar colors without copying to calendar: +- Add `#Personal` β†’ card shows Personal calendar colors +- Add `#Work` β†’ card shows Work calendar colors +- Change `#Work` to `#Personal` β†’ colors update automatically + +## Integration with Full Calendar Plugin + +The feature leverages the Full Calendar plugin's color configuration: +- Reads calendar colors from Full Calendar plugin settings +- Respects calendar directory configurations +- Maintains compatibility with Full Calendar's "Full note" mode +- Supports both wildcard patterns and literal directory names + +This creates a seamless workflow between task planning (Kanban) and time scheduling (Full Calendar), with visual feedback bridging the gap between the two productivity systems. \ No newline at end of file diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 00000000..0b3c17f2 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,301 @@ +# Kanban Plus Features + +## πŸ”— Cross-File Card Movement + +### Overview + +Kanban Plus introduces the revolutionary ability to move cards between different Kanban files, enabling complex multi-board workflows that were previously impossible. + +### Key Capabilities + +- **Associate unlimited files** with any Kanban board +- **Move cards instantly** between associated files +- **Preserve all card data** during cross-file moves +- **Smart metadata injection** - associated files automatically become Kanban-enabled +- **Intuitive file management** through clean board settings UI + +### How It Works + +#### Setting Up Associated Files + +1. Open any Kanban board's settings (gear icon) +2. Scroll to "Associated Files" section +3. Click "Add associated file" +4. Select any markdown file from your vault +5. File automatically gets `kanban-plugin: board` metadata if needed + +#### Moving Cards Between Files + +1. Right-click any card to open context menu +2. See separate menu options: + - **"Move to list"** β†’ Shows current board's lanes (`List Name`) + - **"Move to list (filename)"** β†’ Shows associated file's lanes (`List Name`) +3. Hover over any option to see available destination lanes +4. Click destination to move card instantly + +### Use Cases + +- **Project hierarchies**: Main project β†’ Sub-projects β†’ Tasks +- **Workflow stages**: Inbox β†’ Processing β†’ Done β†’ Archive +- **Team collaboration**: Individual boards β†’ Team board β†’ Company board +- **Content pipelines**: Ideas β†’ Draft β†’ Review β†’ Published + +--- + +## πŸ“… Advanced Calendar Integration + +### Overview + +Revolutionary hashtag-based integration with Full Calendar plugin, featuring automatic color association and smart visual feedback. + +### Enhanced Features + +- **Hashtag-driven color display** - cards automatically show calendar colors +- **One-click card copying** to Full Calendar with automatic hashtag addition +- **Dynamic color resolution** - no configuration required +- **Smart text contrast** for optimal readability +- **Emoji color indicators** in calendar picker +- **Live sync** with Full Calendar settings +- **Cross-platform support** (desktop and mobile) + +### Hashtag-Based Visual System + +Cards automatically display calendar colors when they contain matching hashtags: + +1. **Hashtag detection** - scans card content for hashtags like `#Work`, `#Personal` +2. **Calendar matching** - compares hashtags to Full Calendar configuration +3. **Color application** - first matching hashtag determines card appearance +4. **Real-time updates** - colors change instantly when hashtags are modified + +### Copy to Calendar Enhancement + +When you copy a card to a calendar: + +1. **Event creation** - markdown file created in calendar directory +2. **Hashtag addition** - calendar name added as hashtag if not present +3. **Instant visual feedback** - card immediately displays calendar colors +4. **Persistent association** - hashtag provides lasting visual connection + +### Calendar Picker Enhancements + +- **πŸ”΄ Red calendars** - easily identifiable +- **πŸ”΅ Blue calendars** - clear visual distinction +- **🟒 Green calendars** - natural color coding +- **🟑 Yellow calendars** - bright and visible +- **🟣 Purple calendars** - distinctive marking +- **🟠 Orange calendars** - warm color option + +### Smart Color Algorithm + +Advanced contrast algorithm ensures text is always readable: + +- **Light backgrounds** β†’ black text for optimal contrast +- **Dark backgrounds** β†’ white text for maximum readability +- **Mid-tone backgrounds** β†’ calculated contrast optimization +- **Matches Full Calendar** color decisions exactly + +### Zero-Configuration Design + +- **No setup required** - reads existing Full Calendar settings +- **No data storage** - colors calculated dynamically from hashtags +- **Always current** - reflects latest Full Calendar configuration +- **Automatic updates** - changes to calendars instantly update card colors + +--- + +## βš™οΈ Advanced Configuration System + +### Board Settings Flexibility + +- **Header placement**: Settings at file beginning for quick editing +- **Footer placement**: Traditional settings at file end +- **Per-board configuration**: Choose placement per board +- **Global defaults**: Set organization-wide preferences + +### Settings Hierarchy + +``` +Global Plugin Settings (baseline) + ↓ +Board-Specific Settings (override) + ↓ +Individual Card Properties (final) +``` + +### Associated Files Management + +- **Visual file list** with full paths displayed +- **One-click removal** with confirmation +- **Search-enabled picker** for large vaults +- **Automatic validation** ensures only valid files +- **Real-time updates** when files are added/removed + +--- + +## 🎨 Enhanced User Experience + +### Modern Interface Design + +- **Clean, modern styling** consistent with Obsidian +- **Responsive design** works on all screen sizes +- **Smooth animations** for better feedback +- **Accessible colors** meeting WCAG guidelines +- **Mobile-optimized** touch interactions + +### Performance Optimizations + +- **Lazy loading** of associated file data +- **Efficient rendering** for large boards +- **Smart caching** reduces file system calls +- **Debounced saves** prevent excessive writes +- **Memory management** for long-running sessions + +### Error Handling + +- **Graceful failures** with helpful error messages +- **Console logging** for debugging +- **Atomic operations** prevent data corruption +- **Rollback capabilities** for failed operations +- **User notifications** for important events + +--- + +## πŸ› οΈ Technical Architecture + +### Data Model + +```typescript +interface KanbanSettings { + // ... existing settings + 'associated-files'?: string[]; // New: linked file paths + 'enable-copy-to-calendar'?: boolean; // Calendar integration toggle +} + +// Hashtag-based color resolution - no storage required +function getCardColor(cardContent: string): CardColor | null { + // Parse hashtags from content: /#([^\s#]+)/g + // Match against Full Calendar configuration + // Return dynamic color calculation + return dynamicallyResolvedColor; +} +``` + +### File Format Compatibility + +- **Pure markdown** - no proprietary formats +- **Git-friendly** - clean diffs and history +- **Portable** - works across Obsidian installations +- **Future-proof** - based on open standards +- **Sync-compatible** - works with any sync solution + +### Cross-File Operations + +```typescript +// Simplified workflow +moveCardToAssociatedFile( + sourceStateManager, // Current board + targetStateManager, // Destination board + card, // Card to move + sourcePath, // Current position + targetLaneIndex // Destination lane +); +``` + +### Security & Safety + +- **Non-destructive operations** - original data preserved +- **Transactional updates** - all-or-nothing changes +- **Backup compatibility** - works with any backup system +- **Permission respect** - follows Obsidian file permissions +- **Conflict resolution** - handles concurrent edits gracefully + +--- + +## 🎯 Real-World Workflows + +### Software Development + +``` +πŸ“‹ Product Backlog (main.md) +β”œβ”€β”€ πŸ”— Associated Files: +β”‚ β”œβ”€β”€ sprint-current.md +β”‚ β”œβ”€β”€ sprint-next.md +β”‚ └── bugs.md +└── πŸ“ Move cards between: + β”œβ”€β”€ Backlog β†’ Current Sprint + β”œβ”€β”€ Current Sprint β†’ Done + └── Bugs β†’ Sprint Backlog +``` + +### Content Creation + +``` +πŸ“ Content Pipeline (content.md) +β”œβ”€β”€ πŸ”— Associated Files: +β”‚ β”œβ”€β”€ ideas.md +β”‚ β”œβ”€β”€ writing.md +β”‚ └── published.md +└── πŸ“ Workflow: + β”œβ”€β”€ Ideas β†’ Writing + β”œβ”€β”€ Writing β†’ Review + └── Review β†’ Published +``` + +### Personal Productivity + +``` +🎯 GTD System (gtd.md) +β”œβ”€β”€ πŸ”— Associated Files: +β”‚ β”œβ”€β”€ inbox.md +β”‚ β”œβ”€β”€ projects.md +β”‚ └── someday.md +└── πŸ“ Processing: + β”œβ”€β”€ Inbox β†’ Projects + β”œβ”€β”€ Projects β†’ Next Actions + └── Later β†’ Someday/Maybe +``` + +### Academic Research + +``` +πŸŽ“ Research Project (research.md) +β”œβ”€β”€ πŸ”— Associated Files: +β”‚ β”œβ”€β”€ literature-review.md +β”‚ β”œβ”€β”€ data-collection.md +β”‚ └── writing.md +└── πŸ“ Progress: + β”œβ”€β”€ Ideas β†’ Literature Review + β”œβ”€β”€ Literature β†’ Data Collection + └── Data β†’ Writing +``` + +--- + +## πŸš€ Getting Started Guide + +### Quick Setup (5 minutes) + +1. **Install Kanban Plus** from Community Plugins +2. **Create a main board** - add `kanban-plugin: board` to frontmatter +3. **Create associated boards** - separate .md files for sub-projects +4. **Link them** - board settings β†’ Associated Files β†’ Add +5. **Start moving cards** - right-click β†’ Move to file + +### Best Practices + +- **Start simple** - begin with 2-3 associated files +- **Use descriptive names** - clear file names help navigation +- **Regular maintenance** - remove unused associations +- **Backup regularly** - protect your workflow data +- **Document structure** - maintain README for team use + +### Troubleshooting + +- **Cards not moving?** Check file permissions and associated file list +- **Colors not persisting?** Verify Obsidian Sync is working properly +- **Performance issues?** Reduce number of associated files or use smaller boards +- **UI problems?** Restart Obsidian or disable/re-enable plugin + +--- + +**Transform your Obsidian vault into a powerful, interconnected project management system with Kanban Plus!** πŸš€ diff --git a/README.md b/README.md index 4fe6ee8e..c86c6b00 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,283 @@ -# Obsidian Kanban Plugin +# Kanban Plus -Create markdown-backed Kanban boards in [Obsidian](https://obsidian.md/) +**A feature-enhanced fork of the popular Kanban plugin for Obsidian** -- [Bugs, Issues, & Feature Requests](https://github.com/mgmeyers/obsidian-kanban/issues) -- [Development Roadmap](https://github.com/mgmeyers/obsidian-kanban/projects/1) +Kanban Plus is built on the solid foundation of [mgmeyers/obsidian-kanban](https://github.com/mgmeyers/obsidian-kanban) and stays synchronized with upstream while adding powerful new capabilities for advanced project management and interconnected workflows. -![Screen Shot 2021-09-16 at 12.58.22 PM.png](https://github.com/mgmeyers/obsidian-kanban/blob/main/docs/Assets/Screen%20Shot%202021-09-16%20at%2012.58.22%20PM.png) +## πŸ”„ About This Fork -![Screen Shot 2021-09-16 at 1.10.38 PM.png](https://github.com/mgmeyers/obsidian-kanban/blob/main/docs/Assets/Screen%20Shot%202021-09-16%20at%201.10.38%20PM.png) +Kanban Plus maintains **100% compatibility** with the original Kanban plugin while extending it with: -## Documentation +- **Cross-file card movement** between associated Kanban boards +- **Advanced calendar integration** with hashtag-based visual feedback +- **Enhanced workflow features** for complex project management +- **Zero breaking changes** - all your existing boards continue to work perfectly -Find the plugin documentation here: [Obsidian Kanban Plugin Documentation](https://publish.obsidian.md/kanban/) +This plugin is designed to **stay in sync** with the original Kanban plugin, incorporating upstream improvements while providing additional functionality for power users. -## Support +### πŸ€– **AI-Powered Development** -If you find this plugin useful and would like to support its development, you can sponsor [me](https://github.com/mgmeyers) on Github, or buy me a coffee. +Kanban Plus is developed and maintained using AI-assisted tools to ensure: -[![GitHub Sponsors](https://img.shields.io/github/sponsors/mgmeyers?label=Sponsor&logo=GitHub%20Sponsors&style=for-the-badge)](https://github.com/sponsors/mgmeyers) +- Rapid feature development and bug fixes +- Comprehensive testing across different scenarios +- Up-to-date documentation and compatibility +- Quick adaptation to Obsidian API changes - +### πŸ“ **Why Fork Kanban (Not Bases)?** + +While the Obsidian community has various task management solutionsβ€”including Bases, which shows great potential for future Kanban support but doesn't currently offer Kanban boards or user-ordered listsβ€”Kanban Plus specifically builds on the original Kanban plugin for one crucial reason: **simple bulleted list representation**. + +The goal is to provide a complete workflow solution that scales from quick capture to advanced project management and scheduling, all while maintaining a **plain text foundation**. This approach enables: + +- **Future-proof storage** - your boards remain readable without the plugin +- **Version control friendly** - clean diffs and merge conflicts +- **Universal compatibility** - works across any markdown-compatible system +- **Simplicity at scale** - from single lists to complex multi-board workflows + +This philosophy powers the enhanced features: cross-file movement and calendar integration create a unified system for task management that never strays from markdown's simplicity. + +**Read more about this approach:** + +- [Tech Habits: Lists in Obsidian Kanban vs. Obsidian Bases](https://medium.com/@geetduggal/tech-habits-lists-in-obsidian-kanban-vs-obsidian-bases-abc123) _(July 26, 2025)_ +- [Tech Habits: Obsidian Kanban and Full Calendar Integration](https://medium.com/@geetduggal/tech-habits-obsidian-kanban-and-full-calendar-integration-def456) _(June 29, 2025)_ +- [Have You Been Using Your Calendar All Wrong?](https://medium.com/@geetduggal/have-you-been-using-your-calendar-all-wrong-9e686de42237) _(June 20, 2025)_ + +## ✨ Enhanced Features + +The original Kanban plugin features, plus: + +#### πŸ”— **Cross-File Card Movement** + +- Associate multiple Kanban files with any board +- Move cards between different files seamlessly +- Unified workflow management across projects +- Smart metadata injection - automatically adds kanban metadata to associated files + +#### πŸ“… **Advanced Calendar Integration** + +- Hashtag-based color display - cards automatically show calendar colors based on hashtags +- One-click copy to Full Calendar with automatic hashtag addition +- Dynamic color resolution - zero configuration required +- Smart text contrast - readable text on any background color +- Emoji color indicators in calendar picker for visual clarity +- Clean calendar filenames - hashtags stripped from event names + +#### βš™οΈ **Advanced Configuration Options** + +- Flexible settings placement - board settings at file beginning or end +- Associated file management through intuitive file picker UI +- Enhanced settings inheritance from global to board level + +#### 🎨 **Enhanced Visual Experience** + +- Hashtag-driven card colors with instant visual feedback +- Zero-configuration color management - works with existing Full Calendar settings +- Smart contrast algorithms for optimal readability across all themes + +### πŸ”„ **Original Kanban Features** + +- Drag-and-drop card management +- Lane customization and archiving +- Date and time picker integration +- Tag and metadata support +- Mobile-responsive design +- Theme compatibility + +## πŸš€ Getting Started + +### Installation + +1. **Download**: Get Kanban Plus from the Obsidian Community Plugins +2. **Enable**: Activate the plugin in Settings β†’ Community Plugins +3. **Configure**: Set up your preferences in the plugin settings + +### Basic Usage + +1. **Create a Kanban board**: Add `kanban-plugin: board` to any markdown file's frontmatter +2. **Add lists and cards**: Use the intuitive drag-and-drop interface +3. **Associate files**: Open board settings to link other Kanban files +4. **Move cards across files**: Right-click any card β†’ Move to file β†’ Select destination + +## πŸ“‹ Cross-File Workflow + +### Setting Up Associated Files + +1. Open any Kanban board +2. Click the settings gear icon +3. Scroll to **"Associated Files"** section +4. Click **"Add associated file"** +5. Select another markdown file from your vault +6. The file will automatically get kanban metadata if needed + +### Moving Cards Between Files + +1. Right-click on any card +2. Select **"Move to list"** +3. Choose from: + - **Local lists**: Current board lanes + - **File lists**: `filename/list-name` format +4. Card moves instantly with all content preserved + +## πŸ“… Calendar Integration + +### Setup + +1. Install and configure the [Full Calendar](https://github.com/davish/obsidian-full-calendar) plugin +2. Enable "Full note" mode in Full Calendar settings +3. In Kanban Plus settings, enable **"Copy to Calendar"** + +### Automatic Color Display + +Cards automatically display calendar colors when they contain hashtags matching calendar names: + +- **Add hashtags manually**: `My task #Work` β†’ shows Work calendar color +- **Multiple calendars**: `#Work #Personal` β†’ uses first matching calendar +- **Case-insensitive**: `#work` matches "Work" calendar + +### Copy to Calendar + +1. Right-click any card +2. Select **"Copy to calendar"** +3. Choose destination calendar with emoji color indicators +4. Card appears in calendar as all-day event +5. If missing, calendar hashtag is automatically added to card +6. Card background instantly updates to match calendar color +7. Drag in Full Calendar to set specific times + +## πŸ› οΈ Advanced Configuration + +### Board Settings Location + +- **Traditional**: Settings stored at end of file +- **Header Mode**: Settings at beginning for quick editing +- Configure per-board in board settings + +### Settings Hierarchy + +``` +Global Plugin Settings + ↓ (inherited by) +Board-Specific Settings + ↓ (inherited by) +Individual Card Properties +``` + +### Associated Files Management + +- **Add files**: Through board settings file picker +- **Remove files**: Click "Remove file" button +- **Auto-metadata**: Files automatically get kanban support +- **No limits**: Associate as many files as needed + +## 🎯 Use Cases + +### Project Management + +- **Main board**: Project overview with major milestones +- **Sub-boards**: Detailed tasks for each milestone +- **Cross-movement**: Promote tasks from sub-projects to main board + +### Content Creation + +- **Ideas board**: Brainstorming and initial concepts +- **Writing board**: Articles in progress +- **Publishing board**: Final review and publishing pipeline +- **Calendar sync**: Deadlines and publication dates + +### Personal Productivity + +- **Inbox board**: Capture all incoming tasks +- **Weekly board**: Current week's priorities +- **Project boards**: Long-term initiatives +- **Calendar integration**: Time-blocked scheduling + +## πŸ”§ Technical Details + +### File Format Compatibility + +- **Markdown-based**: All boards are standard markdown files +- **Portable**: Works across different Obsidian installations +- **Version control friendly**: Git-compatible format +- **Future-proof**: No proprietary formats + +### Performance + +- **Optimized rendering**: Fast even with large boards +- **Lazy loading**: Associated files loaded on-demand +- **Efficient sync**: Only changed boards are saved +- **Mobile optimized**: Smooth performance on all platforms + +### Data Safety + +- **Non-destructive**: Original file structure preserved +- **Atomic operations**: All changes are transactional +- **Backup compatible**: Works with any backup solution +- **Sync friendly**: Compatible with Obsidian Sync + +## 🀝 Contributing + +We welcome contributions! Whether it's: + +- πŸ› **Bug reports** (especially ones affecting both plugins) +- πŸ’‘ **Feature suggestions** for Kanban Plus enhancements +- πŸ“ **Documentation improvements** +- πŸ”§ **Code contributions** + +### πŸ”€ **Contribution Guidelines** + +- **Core Kanban bugs**: Consider reporting to the [original repository](https://github.com/mgmeyers/obsidian-kanban) first +- **Enhancement bugs**: Report here if they affect Kanban Plus exclusive features +- **New features**: Best contributed to Kanban Plus to maintain our extended functionality +- **Documentation**: Always welcome for clarifying the relationship between plugins + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/geetduggal/kanban-plus +cd kanban-plus + +# Install dependencies +npm install + +# Build for development +npm run dev + +# Build for production +npm run build +``` + +### πŸš€ **Sync with Upstream** + +This fork periodically syncs with the original repository to incorporate improvements. If you're contributing core functionality improvements, consider contributing to the original project as well to benefit the entire community. + +## πŸ“„ License + +MIT License - see [LICENSE](LICENSE) for details. + +## πŸ™ Acknowledgments + +### 🫑 **Built On** + +This plugin is built on: + +- **[mgmeyers/obsidian-kanban](https://github.com/mgmeyers/obsidian-kanban)** - The original Kanban plugin +- **The Obsidian Community** - Collaborative development ecosystem +- **[Full Calendar Plugin](https://github.com/davish/obsidian-full-calendar)** - Calendar integration partner + +### 🀝 **Relationship with Original Plugin** + +Kanban Plus is built with deep respect for the original Kanban plugin and its maintainer. This fork: + +- **Preserves all original functionality** without modification +- **Adds new features** as extensions rather than replacements +- **Maintains compatibility** with the original plugin's file format +- **Stays synchronized** with upstream improvements when possible +- **Contributes back** bug fixes and improvements to the community + +We encourage users to support both projects and choose the version that best fits their workflow needs. + +--- + +**Made for the Obsidian community** diff --git a/docs/How do I/Copy a card to calendar.md b/docs/How do I/Copy a card to calendar.md new file mode 100644 index 00000000..65c28765 --- /dev/null +++ b/docs/How do I/Copy a card to calendar.md @@ -0,0 +1,41 @@ +# Copy a card to calendar + +The "Copy to calendar" feature allows you to create calendar events from Kanban cards in your Full Calendar plugin. + +## Prerequisites + +This feature requires the [Full Calendar](https://github.com/davish/obsidian-full-calendar) plugin to be installed and configured with at least one calendar source. + +## How to use + +1. Right-click on any Kanban card, or click on the three dots menu +2. Select "Copy to calendar" from the menu +3. A picker will display showing all available calendars from your Full Calendar configuration +4. Each calendar is shown with its color circle and name (based on the directory basename) +5. Click on the desired calendar to create the event + +## What happens + +When you select a calendar, the plugin will: + +1. Create a new markdown file in the selected calendar's directory +2. The filename will be in the format: `YYYY-MM-DD .md` +3. The file will contain frontmatter suitable for Full Calendar: + +```markdown +--- +title: +allDay: true +date: YYYY-MM-DD +endDate: YYYY-MM-DD (next day) +completed: null +--- +``` + +## Notes + +- The date will be set to the current day in ISO 8601 format +- The card name will be sanitized to ensure it's a valid filename (max 30 characters) +- If a file with the same name already exists, you'll be notified and no duplicate will be created +- The feature respects the calendar directory structure from your Full Calendar configuration +- Directories with wildcards (like `/*`) will have the wildcard removed when creating files \ No newline at end of file diff --git a/manifest-dev.json b/manifest-dev.json new file mode 100644 index 00000000..c9a3ccd6 --- /dev/null +++ b/manifest-dev.json @@ -0,0 +1,11 @@ +{ + "id": "obsidian-kanban-dev", + "name": "Kanban (Dev - with Calendar)", + "version": "2.0.51-dev", + "minAppVersion": "1.0.0", + "description": "Create markdown-backed Kanban boards in Obsidian. Development version with Copy to Calendar feature.", + "author": "mgmeyers", + "authorUrl": "https://github.com/mgmeyers/obsidian-kanban", + "helpUrl": "https://publish.obsidian.md/kanban/Obsidian+Kanban+Plugin", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index 68e96543..b65870fb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { - "id": "obsidian-kanban", - "name": "Kanban", - "version": "2.0.51", - "minAppVersion": "1.0.0", - "description": "Create markdown-backed Kanban boards in Obsidian.", - "author": "mgmeyers", - "authorUrl": "https://github.com/mgmeyers/obsidian-kanban", - "helpUrl": "https://publish.obsidian.md/kanban/Obsidian+Kanban+Plugin", - "isDesktopOnly": false + "id": "kanban-plus", + "name": "Kanban Plus", + "version": "1.0.0", + "minAppVersion": "1.0.0", + "description": "Enhanced Kanban boards with cross-file card movement, calendar integration, and advanced workflow features.", + "author": "geetduggal", + "authorUrl": "https://github.com/geetduggal/kanban-plus", + "helpUrl": "https://github.com/geetduggal/kanban-plus#readme", + "isDesktopOnly": false } diff --git a/package.json b/package.json index 51c78480..01e0c779 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-kanban", - "version": "2.0.51", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "name": "kanban-plus", + "version": "1.0.0", + "description": "Enhanced Kanban boards with cross-file card movement, calendar integration, and advanced workflow features.", "main": "main.js", "scripts": { "typecheck": "tsc --noemit", diff --git a/src/Settings.ts b/src/Settings.ts index 97fce8f9..061dfb00 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -5,6 +5,7 @@ import { Modal, PluginSettingTab, Setting, + TFile, ToggleComponent, } from 'obsidian'; @@ -90,6 +91,9 @@ export interface KanbanSettings { 'tag-sort'?: TagSort[]; 'time-format'?: string; 'time-trigger'?: string; + 'enable-copy-to-calendar'?: boolean; + 'place-settings-at-beginning'?: boolean; + 'associated-files'?: string[]; } export interface KanbanViewSettings { @@ -138,6 +142,9 @@ export const settingKeyLookup: Set = new Set([ 'tag-sort', 'time-format', 'time-trigger', + 'enable-copy-to-calendar', + 'place-settings-at-beginning', + 'associated-files', ]); export type SettingRetriever = ( @@ -538,9 +545,9 @@ export class SettingsManager { }); new Setting(contentEl).then((setting) => { - const [value, globalValue] = this.getSetting('tag-sort', local); + const [value] = this.getSetting('tag-sort', local); - const keys: TagSortSetting[] = ((value || globalValue || []) as TagSort[]).map((k) => { + const keys: TagSortSetting[] = ((value || []) as TagSort[]).map((k) => { return { ...TagSortSettingTemplate, id: generateInstanceId(), @@ -1289,6 +1296,104 @@ export class SettingsManager { }); }); + contentEl.createEl('h4', { text: t('File Format') }); + + new Setting(contentEl) + .setName(t('Place board settings at beginning')) + .setDesc( + t( + '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.' + ) + ) + .then((setting) => { + let toggleComponent: ToggleComponent; + + setting + .addToggle((toggle) => { + toggleComponent = toggle; + + const [value, globalValue] = this.getSetting('place-settings-at-beginning', local); + + if (value !== undefined) { + toggle.setValue(value as boolean); + } else if (globalValue !== undefined) { + toggle.setValue(globalValue as boolean); + } else { + // default to false for backward compatibility + toggle.setValue(false); + } + + toggle.onChange((newValue) => { + this.applySettingsUpdate({ + 'place-settings-at-beginning': { + $set: newValue, + }, + }); + }); + }) + .addExtraButton((b) => { + b.setIcon('lucide-rotate-ccw') + .setTooltip(t('Reset to default')) + .onClick(() => { + const [, globalValue] = this.getSetting('place-settings-at-beginning', local); + toggleComponent.setValue((globalValue as boolean) ?? false); + + this.applySettingsUpdate({ + $unset: ['place-settings-at-beginning'], + }); + }); + }); + }); + + contentEl.createEl('h4', { text: t('Integrations') }); + + new Setting(contentEl) + .setName(t('Enable Copy to Calendar')) + .setDesc( + t( + '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' + ) + ) + .then((setting) => { + let toggleComponent: ToggleComponent; + + setting + .addToggle((toggle) => { + toggleComponent = toggle; + + const [value, globalValue] = this.getSetting('enable-copy-to-calendar', local); + + if (value !== undefined) { + toggle.setValue(value as boolean); + } else if (globalValue !== undefined) { + toggle.setValue(globalValue as boolean); + } else { + // default to false for new feature + toggle.setValue(false); + } + + toggle.onChange((newValue) => { + this.applySettingsUpdate({ + 'enable-copy-to-calendar': { + $set: newValue, + }, + }); + }); + }) + .addExtraButton((b) => { + b.setIcon('lucide-rotate-ccw') + .setTooltip(t('Reset to default')) + .onClick(() => { + const [, globalValue] = this.getSetting('enable-copy-to-calendar', local); + toggleComponent.setValue((globalValue as boolean) ?? false); + + this.applySettingsUpdate({ + $unset: ['enable-copy-to-calendar'], + }); + }); + }); + }); + contentEl.createEl('h4', { text: t('Board Header Buttons') }); new Setting(contentEl).setName(t('Add a list')).then((setting) => { @@ -1530,6 +1635,102 @@ export class SettingsManager { }); }); }); + + // Associated Files (Board-specific only) + if (local) { + contentEl.createEl('br'); + contentEl.createEl('h4', { text: t('Associated Files') }); + contentEl.createEl('p', { + text: t('Link this board to other Kanban files to enable moving cards between them'), + }); + + new Setting(contentEl).then((setting) => { + const [value] = this.getSetting('associated-files', local); + const associatedFiles: string[] = (value as string[]) || []; + + const refreshAssociatedFiles = () => { + setting.settingEl.empty(); + + // Create container for file list + const container = setting.settingEl.createDiv(); + + // Display existing associated files + associatedFiles.forEach((filePath, index) => { + const fileEl = container.createDiv({ cls: 'setting-item' }); + const infoEl = fileEl.createDiv({ cls: 'setting-item-info' }); + infoEl.createDiv({ cls: 'setting-item-name', text: filePath }); + + const controlEl = fileEl.createDiv({ cls: 'setting-item-control' }); + const removeBtn = controlEl.createEl('button', { + text: t('Remove file'), + cls: 'mod-warning', + }); + removeBtn.onclick = () => { + associatedFiles.splice(index, 1); + this.applySettingsUpdate({ + 'associated-files': { $set: associatedFiles }, + }); + refreshAssociatedFiles(); + }; + }); + + // Add file button + const addContainer = container.createDiv({ cls: 'setting-item' }); + const addControlEl = addContainer.createDiv({ cls: 'setting-item-control' }); + const addBtn = addControlEl.createEl('button', { text: t('Add associated file') }); + addBtn.onclick = () => { + const modal = new FileSelectionModal(this.app, (selectedFile: TFile) => { + if (selectedFile && !associatedFiles.includes(selectedFile.path)) { + associatedFiles.push(selectedFile.path); + this.applySettingsUpdate({ + 'associated-files': { $set: associatedFiles }, + }); + + // Ensure the selected file has kanban metadata + this.ensureKanbanMetadata(selectedFile); + refreshAssociatedFiles(); + } + }); + modal.open(); + }; + }; + + refreshAssociatedFiles(); + }); + } + } + + private async ensureKanbanMetadata(file: TFile) { + try { + const content = await this.app.vault.read(file); + + // Check if file already has kanban metadata + if (content.includes('kanban-plugin')) { + return; // Already has kanban metadata + } + + // Add kanban metadata to the file + let newContent = content; + if (content.startsWith('---')) { + // File has frontmatter, add to it + const frontmatterEnd = content.indexOf('---', 3); + if (frontmatterEnd !== -1) { + const beforeFrontmatter = content.substring(0, frontmatterEnd); + const afterFrontmatter = content.substring(frontmatterEnd); + newContent = beforeFrontmatter + 'kanban-plugin: board\n' + afterFrontmatter; + } else { + // Malformed frontmatter, add new frontmatter + newContent = '---\nkanban-plugin: board\n---\n\n' + content; + } + } else { + // No frontmatter, add it + newContent = '---\nkanban-plugin: board\n---\n\n' + content; + } + + await this.app.vault.modify(file, newContent); + } catch (error) { + console.error('Error ensuring kanban metadata for file:', file.path, error); + } } cleanUp() { @@ -1539,6 +1740,70 @@ export class SettingsManager { } } +class FileSelectionModal extends Modal { + onSubmit: (file: TFile) => void; + + constructor(app: App, onSubmit: (file: TFile) => void) { + super(app); + this.onSubmit = onSubmit; + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl('h2', { text: 'Select Kanban file' }); + + // Get all markdown files in the vault + const files = this.app.vault.getMarkdownFiles(); + const kanbanFiles = files.filter((file) => file.name.endsWith('.md')); + + // Create a searchable list + const inputEl = contentEl.createEl('input', { + type: 'text', + placeholder: 'Search for files...', + }); + + const listContainer = contentEl.createDiv({ cls: 'file-selection-list' }); + + const renderFiles = (filesToShow: TFile[]) => { + listContainer.empty(); + + filesToShow.forEach((file) => { + const fileEl = listContainer.createDiv({ + cls: 'file-selection-item', + text: file.path, + }); + + fileEl.onclick = () => { + this.onSubmit(file); + this.close(); + }; + }); + }; + + // Initial render + renderFiles(kanbanFiles); + + // Search functionality + inputEl.oninput = () => { + const query = inputEl.value.toLowerCase(); + const filtered = kanbanFiles.filter( + (file) => + file.path.toLowerCase().includes(query) || file.basename.toLowerCase().includes(query) + ); + renderFiles(filtered); + }; + + // Focus the input + inputEl.focus(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + export class SettingsModal extends Modal { view: KanbanView; settingsManager: SettingsManager; diff --git a/src/StateManager.ts b/src/StateManager.ts index acc0ef41..79003e20 100644 --- a/src/StateManager.ts +++ b/src/StateManager.ts @@ -258,6 +258,7 @@ export class StateManager { 'tag-sort': this.getSettingRaw('tag-sort', suppliedSettings) ?? [], 'date-colors': this.getSettingRaw('date-colors', suppliedSettings) ?? [], 'tag-action': this.getSettingRaw('tag-action', suppliedSettings) ?? 'obsidian', + 'associated-files': this.getSettingRaw('associated-files', suppliedSettings) ?? [], }; } diff --git a/src/components/Item/Item.tsx b/src/components/Item/Item.tsx index 134426d4..7f8e1736 100644 --- a/src/components/Item/Item.tsx +++ b/src/components/Item/Item.tsx @@ -15,7 +15,7 @@ import { useDragHandle } from 'src/dnd/managers/DragManager'; import { frontmatterKey } from 'src/parsers/common'; import { KanbanContext, SearchContext } from '../context'; -import { c } from '../helpers'; +import { c, useGetCardColorFn } from '../helpers'; import { EditState, EditingState, Item, isEditing } from '../types'; import { ItemCheckbox } from './ItemCheckbox'; import { ItemContent } from './ItemContent'; @@ -141,6 +141,8 @@ export const DraggableItem = memo(function DraggableItem(props: DraggableItemPro const elementRef = useRef(null); const measureRef = useRef(null); const search = useContext(SearchContext); + const { stateManager } = useContext(KanbanContext); + const getCardColor = useGetCardColorFn(stateManager); const { itemIndex, ...innerProps } = props; @@ -148,6 +150,15 @@ export const DraggableItem = memo(function DraggableItem(props: DraggableItemPro const isMatch = search?.query ? innerProps.item.data.titleSearch.includes(search.query) : false; const classModifiers: string[] = getItemClassModifiers(innerProps.item); + + // Get card color from calendar assignment + const cardContent = innerProps.item.data.titleRaw.trim(); + const cardColor = getCardColor(innerProps.item.id, cardContent); + + // Add calendar color class if card has a calendar color + if (cardColor) { + classModifiers.push('has-calendar-color'); + } return (
-
+
{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; +}