mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
commit
14c39a222b
195 changed files with 45674 additions and 32269 deletions
249
.github/workflows/beta-release.yml
vendored
249
.github/workflows/beta-release.yml
vendored
|
|
@ -1,81 +1,202 @@
|
|||
# .github/workflows/beta-release.yml
|
||||
|
||||
name: 'Automatic Beta Release on PR Commit'
|
||||
name: "Automatic Beta Release on PR Commit"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Trigger on PR creation or when new commits are pushed
|
||||
types: [opened, synchronize]
|
||||
# IMPORTANT: Change 'main' to your default branch if it's different (e.g., 'master')
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
# Trigger on PR creation or when new commits are pushed
|
||||
types: [opened, synchronize]
|
||||
# IMPORTANT: Change 'main' to your default branch if it's different (e.g., 'master')
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
# Only trigger on push to specific branches (more secure)
|
||||
branches:
|
||||
- master
|
||||
- "feat/**"
|
||||
- "release/**"
|
||||
|
||||
env:
|
||||
PLUGIN_NAME: obsidian-task-genius
|
||||
PLUGIN_NAME: obsidian-task-genius
|
||||
|
||||
# Grant permissions for the action to create a release
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
build-and-release-beta:
|
||||
# This job only runs if the latest commit message on the PR contains '[release-beta]'
|
||||
if: contains(github.event.head_commit.message, '[release-beta]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout code'
|
||||
uses: actions/checkout@v4
|
||||
build-and-release-beta:
|
||||
if: |
|
||||
contains(github.event.head_commit.message, '[release-beta]') && (
|
||||
(github.event_name == 'push' && github.actor == github.repository_owner) ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.author_association == 'OWNER')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Use Node.js 22'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# Check if any recent commits contain [release-beta] tag
|
||||
- name: "Check for release-beta tag in commits"
|
||||
id: check_release_tag
|
||||
run: |
|
||||
SHOULD_RELEASE="false"
|
||||
|
||||
- name: 'Install pnpm'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: pnpm install
|
||||
# Security check: only allow releases from the main repository
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.repository }}"
|
||||
echo "Repository: $REPO_NAME, Owner: $REPO_OWNER"
|
||||
|
||||
# --- New Step to read version ---
|
||||
- name: 'Get version from package.json'
|
||||
id: get_version
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
- name: 'Build and package plugin'
|
||||
id: build
|
||||
run: |
|
||||
pnpm run build
|
||||
# Create a directory and copy files for zipping
|
||||
mkdir ${{ env.PLUGIN_NAME }}
|
||||
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}/
|
||||
# Create the zip file for the release, using the version from package.json
|
||||
zip -r ${{ env.PLUGIN_NAME }}-${{ env.VERSION }}.zip ./${{ env.PLUGIN_NAME }}
|
||||
# Add your expected repository info here for extra security
|
||||
# EXPECTED_REPO="your-username/your-repo-name"
|
||||
# if [ "$REPO_NAME" != "$EXPECTED_REPO" ]; then
|
||||
# echo "Release not allowed from repository: $REPO_NAME"
|
||||
# echo "SHOULD_RELEASE=false" >> $GITHUB_OUTPUT
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
- name: 'Create Beta Pre-Release'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Use the body of the PR for the release notes
|
||||
body: |
|
||||
Automated beta release for PR #${{ github.event.pull_request.number }}.
|
||||
Commit: `${{ github.sha }}`
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
echo "Checking PR commits for [release-beta] tag..."
|
||||
# Check the latest commit in the PR
|
||||
LATEST_COMMIT_MSG=$(git log -1 --pretty=format:"%s")
|
||||
echo "Latest commit message: $LATEST_COMMIT_MSG"
|
||||
|
||||
if echo "$LATEST_COMMIT_MSG" | grep -q "\[release-beta\]"; then
|
||||
echo "Found [release-beta] tag in latest commit"
|
||||
SHOULD_RELEASE="true"
|
||||
fi
|
||||
|
||||
# Check user permissions (more restrictive)
|
||||
USER_ASSOCIATION="${{ github.event.pull_request.author_association }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
echo "PR author: $PR_AUTHOR, Association: $USER_ASSOCIATION"
|
||||
|
||||
# Only allow OWNER and COLLABORATOR to trigger releases
|
||||
if [ "$USER_ASSOCIATION" != "OWNER" ] && [ "$USER_ASSOCIATION" != "COLLABORATOR" ]; then
|
||||
echo "User association '$USER_ASSOCIATION' is not authorized for releases"
|
||||
SHOULD_RELEASE="false"
|
||||
fi
|
||||
|
||||
# Additional check: only allow specific users (optional - uncomment and customize)
|
||||
# ALLOWED_USERS="Quorafind,other-username"
|
||||
# if ! echo "$ALLOWED_USERS" | grep -q "$PR_AUTHOR"; then
|
||||
# echo "User '$PR_AUTHOR' is not in allowed users list"
|
||||
# SHOULD_RELEASE="false"
|
||||
# fi
|
||||
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "Checking push commit for [release-beta] tag..."
|
||||
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||
PUSH_AUTHOR="${{ github.event.head_commit.author.username }}"
|
||||
echo "Commit message: $COMMIT_MSG"
|
||||
echo "Push author: $PUSH_AUTHOR"
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -q "\[release-beta\]"; then
|
||||
echo "Found [release-beta] tag in push commit"
|
||||
|
||||
# Check if pusher is authorized (optional - uncomment and customize)
|
||||
# ALLOWED_PUSH_USERS="Quorafind,other-username"
|
||||
# if ! echo "$ALLOWED_PUSH_USERS" | grep -q "$PUSH_AUTHOR"; then
|
||||
# echo "User '$PUSH_AUTHOR' is not authorized to trigger releases via push"
|
||||
# SHOULD_RELEASE="false"
|
||||
# else
|
||||
# SHOULD_RELEASE="true"
|
||||
# fi
|
||||
|
||||
SHOULD_RELEASE="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
${{ github.event.pull_request.body }}
|
||||
|
||||
# Mark this as a pre-release, so it doesn't count as a latest official release
|
||||
prerelease: true
|
||||
|
||||
# Use the version from package.json for the tag and release name
|
||||
tag_name: "v${{ env.VERSION }}"
|
||||
name: "Beta Release v${{ env.VERSION }}"
|
||||
|
||||
# Upload all required assets in one step, using the versioned zip file name
|
||||
files: |
|
||||
${{ env.PLUGIN_NAME }}-${{ env.VERSION }}.zip
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
echo "SHOULD_RELEASE=$SHOULD_RELEASE" >> $GITHUB_OUTPUT
|
||||
echo "Should release: $SHOULD_RELEASE"
|
||||
|
||||
- name: "Use Node.js 22"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: "Install pnpm"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
run: |
|
||||
# Install jq for JSON parsing
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
pnpm install
|
||||
|
||||
- name: "Get version from package.json"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
id: get_version
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
|
||||
|
||||
- name: "Get commit messages since last release"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
id: get_commits
|
||||
run: |
|
||||
# Get all releases (including pre-releases) and find the most recent one
|
||||
echo "Fetching all releases from GitHub API..."
|
||||
LAST_RELEASE=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases?per_page=100" | jq -r '.[0].tag_name // empty' 2>/dev/null || echo "")
|
||||
|
||||
# If no release found via API, try to get the most recent tag with proper semantic version sorting
|
||||
if [ -z "$LAST_RELEASE" ]; then
|
||||
echo "No release found via API, looking for latest tag..."
|
||||
# Get all tags and sort them properly using semantic versioning
|
||||
LAST_RELEASE=$(git tag -l | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+' | sort -V | tail -n 1 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$LAST_RELEASE" ]; then
|
||||
echo "No previous release or tag found, getting all commits from the beginning"
|
||||
COMMIT_MESSAGES=$(git log --pretty=format:"- %s (%an) [%h](https://github.com/${{ github.repository }}/commit/%H)" --no-merges)
|
||||
LAST_RELEASE="(initial)"
|
||||
else
|
||||
echo "Getting commits since last release: $LAST_RELEASE"
|
||||
RELEASE_COMMIT=$(git rev-list -n 1 $LAST_RELEASE 2>/dev/null || git rev-list -n 1 HEAD~10)
|
||||
COMMIT_MESSAGES=$(git log ${RELEASE_COMMIT}..HEAD --pretty=format:"- %s (%an) [%h](https://github.com/${{ github.repository }}/commit/%H)" --no-merges)
|
||||
fi
|
||||
|
||||
if [ -z "$COMMIT_MESSAGES" ]; then
|
||||
COMMIT_MESSAGES="- No new commits since last release"
|
||||
fi
|
||||
echo "COMMIT_MESSAGES<<EOF" >> $GITHUB_ENV
|
||||
echo "$COMMIT_MESSAGES" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
echo "LAST_RELEASE=$LAST_RELEASE" >> $GITHUB_ENV
|
||||
|
||||
- name: "Build and package plugin"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
id: build
|
||||
run: |
|
||||
pnpm run build
|
||||
mkdir ${{ env.PLUGIN_NAME }}
|
||||
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}/
|
||||
zip -r ${{ env.PLUGIN_NAME }}-${{ env.VERSION }}.zip ./${{ env.PLUGIN_NAME }}
|
||||
|
||||
- name: "Create Beta Pre-Release"
|
||||
if: steps.check_release_tag.outputs.SHOULD_RELEASE == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
${{ github.event_name == 'pull_request' && format('🚀 Automated beta release for PR #{0}', github.event.pull_request.number) || '🚀 Automated beta release' }}
|
||||
|
||||
## 📝 Changes since last release${{ env.LAST_RELEASE && format(' ({0})', env.LAST_RELEASE) || '' }}:
|
||||
|
||||
${{ env.COMMIT_MESSAGES }}
|
||||
|
||||
---
|
||||
|
||||
${{ github.event_name == 'pull_request' && github.event.pull_request.body || '' }}
|
||||
prerelease: true
|
||||
tag_name: "v${{ env.VERSION }}"
|
||||
name: "Beta Release v${{ env.VERSION }}"
|
||||
files: |
|
||||
${{ env.PLUGIN_NAME }}-${{ env.VERSION }}.zip
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,3 +39,5 @@ package-lock.json
|
|||
scripts
|
||||
translation-templates
|
||||
._data.json
|
||||
|
||||
styles.css
|
||||
213
README.md
213
README.md
|
|
@ -1,39 +1,200 @@
|
|||
<p align="center">
|
||||
<img src="media/task-genius.svg" alt="Task Genius Logo" width="150">
|
||||
</p>
|
||||
<div align="center">
|
||||
<img src="media/task-genius.webp" alt="Task Genius Logo" width="300">
|
||||
|
||||
Full documentation is available at [Docs](https://taskgenius.md/docs/getting-started).
|
||||
**The Ultimate Task Management Plugin for Obsidian**
|
||||
|
||||
[](https://github.com/Quorafind/Obsidian-Task-Genius)
|
||||
[](https://discord.gg/ARR2rHHX6b)
|
||||
|
||||
Transform your Obsidian vault into a powerful task management system with advanced features, beautiful visualizations, and seamless workflow integration.
|
||||
|
||||
[📖 Documentation](https://taskgenius.md/docs/getting-started) • [🚀 Installation](https://taskgenius.md/docs/installation) • [💬 Community](https://discord.gg/ARR2rHHX6b)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Task Genius is a comprehensive plugin for Obsidian designed to enhance your task and project management workflow.
|
||||
## 🌟 Overview
|
||||
|
||||
| Forecast | Inbox |
|
||||
| --- | --- |
|
||||
Task Genius is a comprehensive task management plugin that transforms Obsidian into a powerful productivity hub. Whether you're managing personal projects, tracking habits, or coordinating complex workflows, Task Genius provides the tools you need while preserving Obsidian's philosophy of plain-text, future-proof note-taking.
|
||||
|
||||
### 📸 Screenshots
|
||||
|
||||
| Forecast View | Inbox Management |
|
||||
|:-------------:|:----------------:|
|
||||
|  |  |
|
||||
|
||||
## Key Features
|
||||
---
|
||||
|
||||
| Feature | Description |
|
||||
| --- | --- |
|
||||
| **Task Progress Bars** | Visualize parent task completion with customizable graphical or text-based progress bars based on sub-task status. Supports headings and non-task list items. |
|
||||
| **Advanced Task Statuses & Cycling** | Define custom task statuses beyond `- [ ]` and `- [x]` (e.g., In Progress `[/]`, Planned `[?]`, Abandoned `[-]`). Easily cycle through statuses with clicks or commands. |
|
||||
| **Date & Priority Management** | Quickly add and modify due dates via a calendar picker (`📅 2023-12-25`) and assign priorities (🔺 Highest, ⏫ High, ..., [#A], [#B], [#C]) through context menus, commands, or clickable icons. |
|
||||
| **In-Editor Task Filtering** | Dynamically filter tasks within a note based on status, content, tags, and relationships (parent/child/sibling) using a toggleable panel. Save and reuse common filters as presets. |
|
||||
| **Task Mover** | Archive completed or specific sets of sub-tasks to a designated file using commands, keeping your active notes clean. |
|
||||
| **Quick Capture** | Rapidly capture tasks or notes to a specified file via an inline panel (`Alt+C`), a global command, or a full-featured modal for adding metadata. |
|
||||
| **Workflow Management** | Define multi-stage workflows (e.g., Todo -> Doing -> Review -> Done) and track tasks through them. Includes options for automatic timestamping, duration tracking, and next-task creation. |
|
||||
| **Dedicated Task View** | A powerful, unified interface (`Task Genius: Open Task Genius view`) to see, sort, filter, and manage all tasks across your vault. Includes modes like Inbox, Forecast, Tags, Projects, and Review. |
|
||||
| **Customizable Settings** | Extensive options to configure the appearance and behavior of all features. |
|
||||
| **Rewards** | Earn rewards for completing tasks. |
|
||||
| **Habit Tracking** | Track your habits with a dedicated view. |
|
||||
## ✨ Core Features
|
||||
|
||||
## Installation
|
||||
### 🎯 **Task Management & Visualization**
|
||||
|
||||
Visit the [Installation](https://taskgenius.md/docs/installation) page for instructions.
|
||||
#### **Smart Progress Tracking**
|
||||
- **Visual Progress Bars**: Automatic progress visualization for parent tasks based on sub-task completion
|
||||
- **Multiple Display Modes**: Choose between graphical bars, text-based indicators, or custom formats
|
||||
- **Heading Integration**: Progress bars work seamlessly with Markdown headings and list items
|
||||
- **Real-time Updates**: Progress updates automatically as you complete sub-tasks
|
||||
|
||||
## Support Me
|
||||
#### **Advanced Task Statuses**
|
||||
- **Extended Status System**: Beyond basic todo/done - includes In Progress `[/]`, Planned `[?]`, Abandoned `[-]`, and more
|
||||
- **Status Cycling**: Click through task statuses or use keyboard shortcuts for rapid updates
|
||||
- **Custom Status Definitions**: Define your own task states with custom icons and behaviors
|
||||
- **Bulk Status Operations**: Update multiple tasks simultaneously
|
||||
|
||||
If you enjoy Task Genius and find it useful, please consider supporting my work by buying me a coffee! It helps me dedicate time to maintaining and improving the plugin.
|
||||
### 📅 **Date & Priority Management**
|
||||
|
||||
<a href="https://www.buymeacoffee.com/boninall" target="_blank"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=boninall&button_colour=6495ED&font_colour=ffffff&font_family=Inter&outline_colour=000000&coffee_colour=FFDD00" alt="Buy Me A Coffee"></a>
|
||||
#### **Intelligent Date Handling**
|
||||
- **Calendar Picker Integration**: Visual date selection with `📅 2023-12-25` format
|
||||
- **Multiple Date Types**: Due dates, start dates, scheduled dates, and completion tracking
|
||||
- **Recurrence Support**: Automatic recurring task generation with flexible patterns
|
||||
- **Daily Note Integration**: Seamlessly work with your daily notes workflow
|
||||
|
||||
#### **Priority System**
|
||||
- **Visual Priority Indicators**: 🔺 Highest, ⏫ High, 🔼 Medium, 🔽 Low, ⏬ Lowest
|
||||
- **Alternative Formats**: Support for `[#A]`, `[#B]`, `[#C]` priority notation
|
||||
- **Context Menu Access**: Right-click to quickly assign priorities
|
||||
- **Smart Sorting**: Automatic task ordering based on priority levels
|
||||
|
||||
### 🔍 **Advanced Filtering & Search**
|
||||
|
||||
#### **In-Editor Task Filtering**
|
||||
- **Dynamic Filtering**: Filter tasks within notes based on status, content, tags, and relationships
|
||||
- **Relationship Awareness**: Filter by parent/child/sibling task relationships
|
||||
- **Filter Presets**: Save and reuse common filter combinations
|
||||
- **Toggle Panel**: Non-intrusive filtering interface that preserves your editing flow
|
||||
|
||||
#### **Global Task Search**
|
||||
- **Vault-wide Discovery**: Find tasks across your entire vault instantly
|
||||
- **Complex Queries**: Combine multiple criteria for precise task discovery
|
||||
- **Saved Searches**: Store frequently used search patterns
|
||||
- **Real-time Results**: Search results update as you type
|
||||
|
||||
### 🏗️ **Workflow Management**
|
||||
|
||||
#### **Multi-Stage Workflows**
|
||||
- **Custom Workflow Definition**: Create workflows like Todo → Doing → Review → Done
|
||||
- **Automatic Timestamping**: Track time spent in each workflow stage
|
||||
- **Duration Analytics**: Understand your workflow bottlenecks and efficiency
|
||||
- **Next-Task Creation**: Automatically generate follow-up tasks
|
||||
|
||||
#### **Specialized Workflows**
|
||||
- **Patent Process Management**: Built-in templates for patent workflows
|
||||
- **Project Management**: Standard project lifecycle templates
|
||||
- **Custom Templates**: Design workflows specific to your needs
|
||||
|
||||
### 📊 **Unified Task Views**
|
||||
|
||||
#### **Multiple View Modes**
|
||||
- **Inbox**: Capture and process new tasks efficiently
|
||||
- **Forecast**: Timeline view of upcoming tasks and deadlines
|
||||
- **Projects**: Organize tasks by project with progress tracking
|
||||
- **Tags**: Group and manage tasks by tag categories
|
||||
- **Review**: Systematic review of task status and progress
|
||||
- **Calendar**: Visual calendar interface for date-based task management
|
||||
- **Gantt Charts**: Project timeline visualization with dependencies
|
||||
|
||||
#### **Customizable Perspectives**
|
||||
- **OmniFocus-Style Views**: Create custom perspectives with advanced filtering
|
||||
- **Saved Configurations**: Store multiple view configurations for different contexts
|
||||
- **Flexible Layouts**: Adjust columns, grouping, and sorting to match your workflow
|
||||
|
||||
### ⚡ **Quick Capture & Automation**
|
||||
|
||||
#### **Rapid Task Entry**
|
||||
- **Quick Capture Panel**: Press `Alt+C` for instant task creation
|
||||
- **Global Commands**: Create tasks from anywhere in Obsidian
|
||||
- **Template Support**: Use templates for consistent task formatting
|
||||
- **Metadata Integration**: Automatically add dates, priorities, and tags
|
||||
|
||||
#### **Task Automation**
|
||||
- **Completed Task Archiving**: Automatically move completed tasks to designated files
|
||||
- **Smart Task Moving**: Bulk operations for task organization
|
||||
- **Auto-completion**: Parent tasks complete when all sub-tasks are done
|
||||
- **Status Propagation**: Intelligent status updates through task hierarchies
|
||||
|
||||
### 🎮 **Gamification & Habits**
|
||||
|
||||
#### **Reward System**
|
||||
- **Achievement Rewards**: Earn rewards for completing tasks and reaching milestones
|
||||
- **Customizable Rewards**: Define your own reward types and conditions
|
||||
- **Inventory Management**: Track reward availability and usage
|
||||
- **Motivational Feedback**: Visual celebrations for task completion
|
||||
|
||||
#### **Habit Tracking**
|
||||
- **Daily Habit Monitoring**: Track habits through daily note metadata
|
||||
- **Streak Tracking**: Monitor consecutive completion days
|
||||
- **Visual Calendar**: See habit completion patterns at a glance
|
||||
- **Multiple Habit Types**: Support for daily, weekly, and custom frequency habits
|
||||
- **Progress Analytics**: Understand your habit formation patterns
|
||||
|
||||
### 🛠️ **Advanced Configuration**
|
||||
|
||||
#### **Extensive Customization**
|
||||
- **Appearance Settings**: Customize colors, icons, and visual elements
|
||||
- **Behavior Configuration**: Adjust plugin behavior to match your workflow
|
||||
- **Keyboard Shortcuts**: Comprehensive hotkey support for all major functions
|
||||
- **Integration Options**: Configure compatibility with other Obsidian plugins
|
||||
|
||||
#### **Data Management**
|
||||
- **Tasks Plugin Compatibility**: Full compatibility with the popular Tasks plugin
|
||||
- **Import/Export**: Migrate configurations and data between vaults
|
||||
- **Backup & Restore**: Protect your task management setup
|
||||
- **Performance Optimization**: Efficient indexing for large vaults
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Community Plugin**: Install directly from Obsidian's Community Plugins
|
||||
2. **Manual Installation**: Download from [releases](https://github.com/Quorafind/Obsidian-Task-Genius/releases)
|
||||
3. **Beta Testing**: Join our [Discord](https://discord.gg/ARR2rHHX6b) for early access to new features
|
||||
|
||||
For detailed installation instructions, visit our [Installation Guide](https://taskgenius.md/docs/installation).
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Enable the Plugin**: Activate Task Genius in your plugin settings
|
||||
2. **Open Task View**: Use the ribbon icon or command palette (`Task Genius: Open Task Genius view`)
|
||||
3. **Configure Your Workflow**: Customize settings to match your task management style
|
||||
4. **Start Creating Tasks**: Begin with simple `- [ ]` tasks and explore advanced features
|
||||
|
||||
### First Steps
|
||||
|
||||
- **Create Your First Task**: Try `- [ ] Learn Task Genius features 📅 2024-01-15 🔺`
|
||||
- **Explore Views**: Switch between Inbox, Forecast, and Projects views
|
||||
- **Set Up Quick Capture**: Configure `Alt+C` for rapid task entry
|
||||
- **Join the Community**: Connect with other users on [Discord](https://discord.gg/ARR2rHHX6b)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation & Support
|
||||
|
||||
### Resources
|
||||
|
||||
- **📖 [Complete Documentation](https://taskgenius.md/docs/getting-started)**: Comprehensive guides and tutorials
|
||||
|
||||
### Community & Support
|
||||
|
||||
- **💬 [Discord Community](https://discord.gg/ARR2rHHX6b)**: Get help, share tips, and connect with other users
|
||||
- **🐛 [Issue Tracker](https://github.com/Quorafind/Obsidian-Task-Genius/issues)**: Report bugs and request features
|
||||
- **💡 [Feature Requests](https://github.com/Quorafind/Obsidian-Task-Genius/discussions)**: Suggest new features and improvements
|
||||
|
||||
---
|
||||
|
||||
## 💝 Support the Project
|
||||
|
||||
Task Genius is developed with passion and dedication. If you find it valuable for your productivity workflow, consider supporting its continued development:
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.buymeacoffee.com/boninall" target="_blank">
|
||||
<img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=☕&slug=boninall&button_colour=6495ED&font_colour=ffffff&font_family=Inter&outline_colour=000000&coffee_colour=FFDD00" alt="Buy Me A Coffee" height="50">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Your support helps:
|
||||
- 🚀 **Accelerate Development**: More time for new features and improvements
|
||||
- 🐛 **Faster Bug Fixes**: Quicker response to issues and problems
|
||||
- 📚 **Better Documentation**: Comprehensive guides and tutorials
|
||||
- 🎯 **Feature Requests**: Priority development of community-requested features
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testEnvironment: "jsdom",
|
||||
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||
moduleNameMapper: {
|
||||
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
|
||||
|
|
@ -20,4 +20,5 @@ module.exports = {
|
|||
],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/test-setup.ts"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "obsidian-task-progress-bar",
|
||||
"name": "Task Genius",
|
||||
"version": "8.10.1",
|
||||
"version": "9.0.0-beta.11",
|
||||
"minAppVersion": "0.15.2",
|
||||
"description": "Comprehensive task management that includes progress bars, task status cycling, and advanced task tracking features.",
|
||||
"author": "Boninall",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 3.1 KiB |
BIN
media/task-genius.webp
Normal file
BIN
media/task-genius.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-task-progress-bar",
|
||||
"version": "8.10.1",
|
||||
"name": "task-genius",
|
||||
"version": "9.0.0-beta.11",
|
||||
"description": "Comprehensive task management plugin for Obsidian with progress bars, task status cycling, and advanced task tracking features.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
"task marks"
|
||||
],
|
||||
"author": "Boninall",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "https://github.com/lishid/cm-language",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -25,3 +25,75 @@ export class WidgetType {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewPlugin {
|
||||
static fromClass(cls: any, spec?: any) {
|
||||
return {
|
||||
extension: true,
|
||||
cls,
|
||||
spec,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewUpdate {
|
||||
docChanged: boolean = false;
|
||||
selectionSet: boolean = false;
|
||||
viewportChanged: boolean = false;
|
||||
view: EditorView;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
export class Decoration {
|
||||
static none = {
|
||||
size: 0,
|
||||
update: () => Decoration.none,
|
||||
};
|
||||
|
||||
static widget(spec: any) {
|
||||
return {
|
||||
spec,
|
||||
range: (from: number, to: number) => ({ from, to, spec }),
|
||||
};
|
||||
}
|
||||
|
||||
static replace(spec: any) {
|
||||
return {
|
||||
spec,
|
||||
range: (from: number, to: number) => ({ from, to, spec }),
|
||||
};
|
||||
}
|
||||
|
||||
static set(decorations: any[]) {
|
||||
return {
|
||||
size: decorations.length,
|
||||
update: () => Decoration.none,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DecorationSet {
|
||||
static empty = Decoration.none;
|
||||
size: number = 0;
|
||||
|
||||
update(spec: any) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class MatchDecorator {
|
||||
constructor(spec: any) {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
createDeco(view: EditorView) {
|
||||
return Decoration.none;
|
||||
}
|
||||
|
||||
updateDeco(update: ViewUpdate, decorations: DecorationSet) {
|
||||
return decorations;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
src/__mocks__/dom-helpers.ts
Normal file
1
src/__mocks__/dom-helpers.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "obsidian";
|
||||
|
|
@ -189,15 +189,95 @@ export class FuzzyMatch<T> {
|
|||
}
|
||||
|
||||
// Mock moment function and its methods
|
||||
function momentFn() {
|
||||
return {
|
||||
format: function () {
|
||||
return "2023-01-01 00:00:00";
|
||||
function momentFn(input?: any) {
|
||||
// Parse the input to a Date object
|
||||
let date: Date;
|
||||
if (input instanceof Date) {
|
||||
date = input;
|
||||
} else if (typeof input === "string") {
|
||||
date = new Date(input);
|
||||
} else if (typeof input === "number") {
|
||||
date = new Date(input);
|
||||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
const mockMoment = {
|
||||
format: function (format?: string) {
|
||||
if (format === "YYYY-MM-DD") {
|
||||
return date.toISOString().split("T")[0];
|
||||
} else if (format === "D") {
|
||||
return date.getDate().toString();
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
},
|
||||
diff: function () {
|
||||
return 0;
|
||||
},
|
||||
startOf: function (unit: string) {
|
||||
return mockMoment;
|
||||
},
|
||||
endOf: function (unit: string) {
|
||||
return mockMoment;
|
||||
},
|
||||
isSame: function (other: any, unit?: string) {
|
||||
if (other && other._date instanceof Date) {
|
||||
const thisDate = date.toISOString().split("T")[0];
|
||||
const otherDate = other._date.toISOString().split("T")[0];
|
||||
return thisDate === otherDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isSameOrBefore: function (other: any, unit?: string) {
|
||||
return true;
|
||||
},
|
||||
isSameOrAfter: function (other: any, unit?: string) {
|
||||
return true;
|
||||
},
|
||||
isBefore: function (other: any, unit?: string) {
|
||||
return false;
|
||||
},
|
||||
isAfter: function (other: any, unit?: string) {
|
||||
return false;
|
||||
},
|
||||
isBetween: function (
|
||||
start: any,
|
||||
end: any,
|
||||
unit?: string,
|
||||
inclusivity?: string
|
||||
) {
|
||||
return true;
|
||||
},
|
||||
clone: function () {
|
||||
return momentFn(date);
|
||||
},
|
||||
add: function (amount: number, unit: string) {
|
||||
return mockMoment;
|
||||
},
|
||||
subtract: function (amount: number, unit: string) {
|
||||
return mockMoment;
|
||||
},
|
||||
valueOf: function () {
|
||||
return date.getTime();
|
||||
},
|
||||
toDate: function () {
|
||||
return date;
|
||||
},
|
||||
weekday: function (day?: number) {
|
||||
if (day !== undefined) {
|
||||
return mockMoment;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
day: function () {
|
||||
return date.getDay();
|
||||
},
|
||||
date: function () {
|
||||
return date.getDate();
|
||||
},
|
||||
_date: date,
|
||||
};
|
||||
return mockMoment;
|
||||
}
|
||||
|
||||
// Add static methods to momentFn
|
||||
|
|
@ -221,6 +301,94 @@ function momentFn() {
|
|||
return locale || "en";
|
||||
};
|
||||
|
||||
(momentFn as any).weekdaysShort = function (localeData?: boolean) {
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
};
|
||||
|
||||
(momentFn as any).weekdaysMin = function (localeData?: boolean) {
|
||||
return ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
};
|
||||
|
||||
export const moment = momentFn as any;
|
||||
|
||||
// Mock Component class
|
||||
export class Component {
|
||||
private children: Component[] = [];
|
||||
private loaded = false;
|
||||
|
||||
addChild(component: Component): Component {
|
||||
this.children.push(component);
|
||||
if (this.loaded) {
|
||||
component.load();
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
removeChild(component: Component): Component {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
component.unload();
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loaded = true;
|
||||
this.children.forEach((child) => child.load());
|
||||
this.onload();
|
||||
}
|
||||
|
||||
unload(): void {
|
||||
this.loaded = false;
|
||||
this.children.forEach((child) => child.unload());
|
||||
this.onunload();
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
registerDomEvent(
|
||||
el: HTMLElement,
|
||||
type: string,
|
||||
listener: EventListener
|
||||
): void {
|
||||
// Mock implementation
|
||||
el.addEventListener(type, listener);
|
||||
}
|
||||
|
||||
registerInterval(id: number): number {
|
||||
// Mock implementation
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock other common Obsidian utilities
|
||||
export function setIcon(el: HTMLElement, iconId: string): void {
|
||||
// Mock implementation
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number,
|
||||
immediate?: boolean
|
||||
): T {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
return ((...args: any[]) => {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func(...args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func(...args);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// Add any other Obsidian classes or functions needed for tests
|
||||
|
|
|
|||
278
src/__tests__/CanvasFileMonitoring.test.ts
Normal file
278
src/__tests__/CanvasFileMonitoring.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* Tests for Canvas file monitoring functionality
|
||||
* This test verifies that Canvas files are properly monitored for changes
|
||||
*/
|
||||
|
||||
import { TFile } from 'obsidian';
|
||||
import { isSupportedFile, isCanvasFile } from '../utils/fileTypeUtils';
|
||||
import { CanvasData } from '../types/canvas';
|
||||
|
||||
// Mock TFile for testing
|
||||
class MockTFile {
|
||||
constructor(
|
||||
public path: string,
|
||||
public extension: string,
|
||||
public stat: { mtime: number } = { mtime: Date.now() }
|
||||
) {}
|
||||
|
||||
get name() {
|
||||
return this.path.split('/').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Vault for file monitoring tests
|
||||
class MockVault {
|
||||
private files: Map<string, string> = new Map();
|
||||
private eventListeners: Map<string, Function[]> = new Map();
|
||||
|
||||
getFiles(): TFile[] {
|
||||
return Array.from(this.files.keys()).map(path => {
|
||||
const extension = path.split('.').pop() || '';
|
||||
return new MockTFile(path, extension) as any;
|
||||
});
|
||||
}
|
||||
|
||||
getFileByPath(path: string): TFile | null {
|
||||
if (this.files.has(path)) {
|
||||
const extension = path.split('.').pop() || '';
|
||||
return new MockTFile(path, extension) as any;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async read(file: TFile): Promise<string> {
|
||||
return this.files.get(file.path) || '';
|
||||
}
|
||||
|
||||
async modify(file: TFile, content: string): Promise<void> {
|
||||
this.files.set(file.path, content);
|
||||
this.triggerEvent('modify', file);
|
||||
}
|
||||
|
||||
setFileContent(path: string, content: string): void {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
|
||||
getFileContent(path: string): string | undefined {
|
||||
return this.files.get(path);
|
||||
}
|
||||
|
||||
// Mock event system
|
||||
on(eventName: string, callback: Function): any {
|
||||
if (!this.eventListeners.has(eventName)) {
|
||||
this.eventListeners.set(eventName, []);
|
||||
}
|
||||
this.eventListeners.get(eventName)!.push(callback);
|
||||
return { unload: () => {} }; // Mock event reference
|
||||
}
|
||||
|
||||
private triggerEvent(eventName: string, ...args: any[]): void {
|
||||
const listeners = this.eventListeners.get(eventName) || [];
|
||||
listeners.forEach(listener => listener(...args));
|
||||
}
|
||||
|
||||
// Simulate file creation
|
||||
createFile(path: string, content: string): TFile {
|
||||
this.setFileContent(path, content);
|
||||
const extension = path.split('.').pop() || '';
|
||||
const file = new MockTFile(path, extension) as any;
|
||||
this.triggerEvent('create', file);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Simulate file deletion
|
||||
deleteFile(path: string): void {
|
||||
const extension = path.split('.').pop() || '';
|
||||
const file = new MockTFile(path, extension) as any;
|
||||
this.files.delete(path);
|
||||
this.triggerEvent('delete', file);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Canvas File Monitoring', () => {
|
||||
let mockVault: MockVault;
|
||||
let modifyEventTriggered: boolean;
|
||||
let createEventTriggered: boolean;
|
||||
let deleteEventTriggered: boolean;
|
||||
let lastModifiedFile: TFile | null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = new MockVault();
|
||||
modifyEventTriggered = false;
|
||||
createEventTriggered = false;
|
||||
deleteEventTriggered = false;
|
||||
lastModifiedFile = null;
|
||||
|
||||
// Set up event listeners to track events
|
||||
mockVault.on('modify', (file: TFile) => {
|
||||
if (isSupportedFile(file)) {
|
||||
modifyEventTriggered = true;
|
||||
lastModifiedFile = file;
|
||||
}
|
||||
});
|
||||
|
||||
mockVault.on('create', (file: TFile) => {
|
||||
if (isSupportedFile(file)) {
|
||||
createEventTriggered = true;
|
||||
}
|
||||
});
|
||||
|
||||
mockVault.on('delete', (file: TFile) => {
|
||||
if (isSupportedFile(file)) {
|
||||
deleteEventTriggered = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Type Detection for Monitoring', () => {
|
||||
it('should correctly identify Canvas files as supported for monitoring', () => {
|
||||
const canvasFile = new MockTFile('test.canvas', 'canvas') as any;
|
||||
expect(isSupportedFile(canvasFile)).toBe(true);
|
||||
expect(isCanvasFile(canvasFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify markdown files as supported for monitoring', () => {
|
||||
const mdFile = new MockTFile('test.md', 'md') as any;
|
||||
expect(isSupportedFile(mdFile)).toBe(true);
|
||||
expect(isCanvasFile(mdFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject unsupported file types for monitoring', () => {
|
||||
const txtFile = new MockTFile('test.txt', 'txt') as any;
|
||||
expect(isSupportedFile(txtFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas File Modification Events', () => {
|
||||
it('should trigger modify event when Canvas file is changed', async () => {
|
||||
// Create a Canvas file
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'test-node',
|
||||
type: 'text',
|
||||
text: '# Test\n\n- [ ] Original task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasFile = mockVault.createFile('test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
// Reset event flags
|
||||
modifyEventTriggered = false;
|
||||
lastModifiedFile = null;
|
||||
|
||||
// Modify the Canvas file
|
||||
const updatedCanvasData = {
|
||||
...canvasData,
|
||||
nodes: [
|
||||
{
|
||||
...canvasData.nodes[0],
|
||||
text: '# Test\n\n- [x] Original task'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await mockVault.modify(canvasFile, JSON.stringify(updatedCanvasData, null, 2));
|
||||
|
||||
// Verify that the modify event was triggered
|
||||
expect(modifyEventTriggered).toBe(true);
|
||||
expect(lastModifiedFile).toBeTruthy();
|
||||
expect((lastModifiedFile as any).path).toBe('test.canvas');
|
||||
});
|
||||
|
||||
it('should trigger create event when new Canvas file is created', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'new-node',
|
||||
type: 'text',
|
||||
text: '# New Canvas\n\n- [ ] New task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.createFile('new.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
expect(createEventTriggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger delete event when Canvas file is deleted', () => {
|
||||
// First create a Canvas file
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'delete-node',
|
||||
type: 'text',
|
||||
text: '# To Delete\n\n- [ ] Task to delete',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.createFile('delete.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
// Reset event flags
|
||||
deleteEventTriggered = false;
|
||||
|
||||
// Delete the file
|
||||
mockVault.deleteFile('delete.canvas');
|
||||
|
||||
expect(deleteEventTriggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed File Type Monitoring', () => {
|
||||
it('should handle both Canvas and Markdown files in monitoring', () => {
|
||||
let canvasEventTriggered = false;
|
||||
let markdownEventTriggered = false;
|
||||
|
||||
// Set up specific listeners for different file types
|
||||
mockVault.on('modify', (file: TFile) => {
|
||||
if (isCanvasFile(file)) {
|
||||
canvasEventTriggered = true;
|
||||
} else if (file.extension === 'md') {
|
||||
markdownEventTriggered = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Create and modify Canvas file
|
||||
const canvasFile = mockVault.createFile('mixed.canvas', JSON.stringify({
|
||||
nodes: [{ id: 'test', type: 'text', text: '- [ ] Canvas task', x: 0, y: 0, width: 100, height: 100 }],
|
||||
edges: []
|
||||
}, null, 2));
|
||||
|
||||
// Create and modify Markdown file
|
||||
const mdFile = mockVault.createFile('mixed.md', '# Test\n\n- [ ] Markdown task');
|
||||
|
||||
// Reset flags
|
||||
canvasEventTriggered = false;
|
||||
markdownEventTriggered = false;
|
||||
|
||||
// Modify both files
|
||||
mockVault.modify(canvasFile, JSON.stringify({
|
||||
nodes: [{ id: 'test', type: 'text', text: '- [x] Canvas task', x: 0, y: 0, width: 100, height: 100 }],
|
||||
edges: []
|
||||
}, null, 2));
|
||||
|
||||
mockVault.modify(mdFile, '# Test\n\n- [x] Markdown task');
|
||||
|
||||
expect(canvasEventTriggered).toBe(true);
|
||||
expect(markdownEventTriggered).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
331
src/__tests__/CanvasIntegration.test.ts
Normal file
331
src/__tests__/CanvasIntegration.test.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* Integration tests for Canvas file support
|
||||
*/
|
||||
|
||||
import { isSupportedFile, getFileType, SupportedFileType } from '../utils/fileTypeUtils';
|
||||
import { CanvasParser } from '../utils/parsing/CanvasParser';
|
||||
import { getConfig } from '../common/task-parser-config';
|
||||
|
||||
// Mock TFile for testing
|
||||
class MockTFile {
|
||||
constructor(public path: string, public extension: string) {}
|
||||
}
|
||||
|
||||
describe('Canvas Integration', () => {
|
||||
describe('File Type Detection', () => {
|
||||
it('should detect canvas files as supported', () => {
|
||||
const canvasFile = new MockTFile('test.canvas', 'canvas') as any;
|
||||
expect(isSupportedFile(canvasFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect markdown files as supported', () => {
|
||||
const mdFile = new MockTFile('test.md', 'md') as any;
|
||||
expect(isSupportedFile(mdFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject unsupported file types', () => {
|
||||
const txtFile = new MockTFile('test.txt', 'txt') as any;
|
||||
expect(isSupportedFile(txtFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify file types', () => {
|
||||
const canvasFile = new MockTFile('test.canvas', 'canvas') as any;
|
||||
const mdFile = new MockTFile('test.md', 'md') as any;
|
||||
const txtFile = new MockTFile('test.txt', 'txt') as any;
|
||||
|
||||
expect(getFileType(canvasFile)).toBe(SupportedFileType.CANVAS);
|
||||
expect(getFileType(mdFile)).toBe(SupportedFileType.MARKDOWN);
|
||||
expect(getFileType(txtFile)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas Task Parsing', () => {
|
||||
let parser: CanvasParser;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig('tasks');
|
||||
parser = new CanvasParser(config);
|
||||
});
|
||||
|
||||
it('should parse a realistic canvas file with multiple text nodes', () => {
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "planning-node",
|
||||
type: "text",
|
||||
text: "# Project Planning\n\n- [ ] Define requirements\n- [ ] Create wireframes\n- [x] Set up project structure",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200,
|
||||
color: "1"
|
||||
},
|
||||
{
|
||||
id: "development-node",
|
||||
type: "text",
|
||||
text: "## Development Tasks\n\n- [ ] Implement authentication\n- [ ] Build user interface\n- [ ] Add data validation",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200,
|
||||
color: "2"
|
||||
},
|
||||
{
|
||||
id: "testing-node",
|
||||
type: "text",
|
||||
text: "### Testing\n\n- [ ] Write unit tests\n- [ ] Perform integration testing\n- [ ] User acceptance testing",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
height: 200,
|
||||
color: "3"
|
||||
},
|
||||
{
|
||||
id: "file-reference",
|
||||
type: "file",
|
||||
file: "project-notes.md",
|
||||
x: 500,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge1",
|
||||
fromNode: "planning-node",
|
||||
toNode: "development-node"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'project.canvas');
|
||||
|
||||
// Should find 8 tasks total (3 + 3 + 2, excluding the completed one)
|
||||
expect(tasks.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that we have tasks from different nodes
|
||||
const taskContents = tasks.map(t => t.content);
|
||||
expect(taskContents).toContain('Define requirements');
|
||||
expect(taskContents).toContain('Implement authentication');
|
||||
expect(taskContents).toContain('Write unit tests');
|
||||
|
||||
// Check that completed task is marked correctly
|
||||
const completedTask = tasks.find(t => t.content === 'Set up project structure');
|
||||
expect(completedTask?.completed).toBe(true);
|
||||
|
||||
// Check canvas-specific metadata
|
||||
const firstTask = tasks[0];
|
||||
expect((firstTask.metadata as any).sourceType).toBe('canvas');
|
||||
expect((firstTask.metadata as any).canvasNodeId).toBeDefined();
|
||||
expect((firstTask.metadata as any).canvasPosition).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle canvas with complex markdown in text nodes', () => {
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "complex-node",
|
||||
type: "text",
|
||||
text: `# Complex Node
|
||||
|
||||
This node contains various markdown elements:
|
||||
|
||||
## Tasks with metadata
|
||||
- [ ] Task with due date 📅 2024-01-15
|
||||
- [x] Completed task with priority ⏫
|
||||
- [ ] Task with tags #important #urgent
|
||||
|
||||
## Regular content
|
||||
Some regular text that should not be parsed as tasks.
|
||||
|
||||
### More tasks
|
||||
- [ ] Another task
|
||||
- [ ] Task with project +ProjectName`,
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 400,
|
||||
height: 300
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'complex.canvas');
|
||||
|
||||
expect(tasks.length).toBeGreaterThan(0);
|
||||
|
||||
// Should parse tasks with metadata
|
||||
const taskWithDate = tasks.find(t => t.content.includes('due date'));
|
||||
expect(taskWithDate).toBeDefined();
|
||||
|
||||
const completedTask = tasks.find(t => t.content.includes('priority'));
|
||||
expect(completedTask?.completed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty or invalid canvas files gracefully', () => {
|
||||
// Empty canvas
|
||||
const emptyCanvas = JSON.stringify({ nodes: [], edges: [] });
|
||||
const emptyTasks = parser.parseCanvasFile(emptyCanvas, 'empty.canvas');
|
||||
expect(emptyTasks).toHaveLength(0);
|
||||
|
||||
// Invalid JSON
|
||||
const invalidTasks = parser.parseCanvasFile('invalid json', 'invalid.canvas');
|
||||
expect(invalidTasks).toHaveLength(0);
|
||||
|
||||
// Missing required properties
|
||||
const incompleteTasks = parser.parseCanvasFile('{"nodes": []}', 'incomplete.canvas');
|
||||
expect(incompleteTasks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas Task Metadata', () => {
|
||||
let parser: CanvasParser;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig('tasks');
|
||||
parser = new CanvasParser(config);
|
||||
});
|
||||
|
||||
it('should properly identify canvas tasks by sourceType', () => {
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "test-node",
|
||||
type: "text",
|
||||
text: "- [ ] Canvas task",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
expect(tasks.length).toBe(1);
|
||||
|
||||
const task = tasks[0];
|
||||
expect((task.metadata as any).sourceType).toBe('canvas');
|
||||
});
|
||||
|
||||
it('should include canvas-specific metadata fields', () => {
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "metadata-node",
|
||||
type: "text",
|
||||
text: "- [ ] Task with full metadata",
|
||||
x: 150,
|
||||
y: 250,
|
||||
width: 350,
|
||||
height: 200,
|
||||
color: "2"
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'metadata.canvas');
|
||||
expect(tasks.length).toBe(1);
|
||||
|
||||
const task = tasks[0];
|
||||
const metadata = task.metadata as any;
|
||||
|
||||
expect(metadata.sourceType).toBe('canvas');
|
||||
expect(metadata.canvasNodeId).toBe('metadata-node');
|
||||
expect(metadata.canvasPosition).toEqual({
|
||||
x: 150,
|
||||
y: 250,
|
||||
width: 350,
|
||||
height: 200
|
||||
});
|
||||
expect(metadata.canvasColor).toBe('2');
|
||||
});
|
||||
|
||||
it('should mark tasks as canvas even when source node cannot be found', () => {
|
||||
// This tests the fallback case where findSourceNode returns null
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "test-node",
|
||||
type: "text",
|
||||
text: "- [ ] Task that might not be found",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
expect(tasks.length).toBe(1);
|
||||
|
||||
const task = tasks[0];
|
||||
// Even if source node matching fails, it should still be marked as canvas
|
||||
expect((task.metadata as any).sourceType).toBe('canvas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parser Configuration', () => {
|
||||
it('should respect different metadata formats', () => {
|
||||
const tasksConfig = getConfig('tasks');
|
||||
const dataviewConfig = getConfig('dataview');
|
||||
|
||||
const tasksParser = new CanvasParser(tasksConfig);
|
||||
const dataviewParser = new CanvasParser(dataviewConfig);
|
||||
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "test-node",
|
||||
type: "text",
|
||||
text: "- [ ] Task with due date 📅 2024-01-15",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasksTasks = tasksParser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
const dataviewTasks = dataviewParser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
// Both should parse the task, but metadata handling might differ
|
||||
expect(tasksTasks.length).toBeGreaterThan(0);
|
||||
expect(dataviewTasks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should allow updating parser configuration', () => {
|
||||
const initialConfig = getConfig('tasks');
|
||||
const parser = new CanvasParser(initialConfig);
|
||||
|
||||
const newConfig = getConfig('dataview');
|
||||
parser.updateParserConfig(newConfig);
|
||||
|
||||
// Parser should continue to work with new config
|
||||
const canvasContent = JSON.stringify({
|
||||
nodes: [
|
||||
{
|
||||
id: "test-node",
|
||||
type: "text",
|
||||
text: "- [ ] Test task",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
});
|
||||
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
expect(tasks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
280
src/__tests__/CanvasParser.test.ts
Normal file
280
src/__tests__/CanvasParser.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Tests for Canvas file parsing functionality
|
||||
*/
|
||||
|
||||
import { CanvasParser } from '../utils/parsing/CanvasParser';
|
||||
import { CanvasData, CanvasTextData } from '../types/canvas';
|
||||
import { getConfig } from '../common/task-parser-config';
|
||||
|
||||
describe('CanvasParser', () => {
|
||||
let parser: CanvasParser;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig('tasks');
|
||||
parser = new CanvasParser(config);
|
||||
});
|
||||
|
||||
describe('parseCanvasFile', () => {
|
||||
it('should parse a simple canvas with text nodes containing tasks', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
type: 'text',
|
||||
text: '- [ ] Task 1\n- [x] Task 2 completed',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData,
|
||||
{
|
||||
id: 'node2',
|
||||
type: 'text',
|
||||
text: '- [ ] Another task\n- [ ] Yet another task',
|
||||
x: 300,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(4);
|
||||
expect(tasks[0].content).toBe('Task 1');
|
||||
expect(tasks[0].completed).toBe(false);
|
||||
expect(tasks[1].content).toBe('Task 2 completed');
|
||||
expect(tasks[1].completed).toBe(true);
|
||||
expect(tasks[2].content).toBe('Another task');
|
||||
expect(tasks[3].content).toBe('Yet another task');
|
||||
});
|
||||
|
||||
it('should handle canvas with no text nodes', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'file1',
|
||||
type: 'file',
|
||||
file: 'some-file.md',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle canvas with text nodes but no tasks', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
type: 'text',
|
||||
text: 'This is just regular text\nNo tasks here',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should add canvas-specific metadata to tasks', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
type: 'text',
|
||||
text: '- [ ] Task with metadata',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 150,
|
||||
color: '#ff0000'
|
||||
} as CanvasTextData
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
const task = tasks[0];
|
||||
expect((task.metadata as any).canvasNodeId).toBe('node1');
|
||||
expect((task.metadata as any).canvasPosition).toEqual({
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 150
|
||||
});
|
||||
expect((task.metadata as any).canvasColor).toBe('#ff0000');
|
||||
expect((task.metadata as any).sourceType).toBe('canvas');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', () => {
|
||||
const invalidJson = '{ invalid json }';
|
||||
const tasks = parser.parseCanvasFile(invalidJson, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle mixed node types', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'text1',
|
||||
type: 'text',
|
||||
text: '- [ ] Text node task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData,
|
||||
{
|
||||
id: 'file1',
|
||||
type: 'file',
|
||||
file: 'some-file.md',
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
},
|
||||
{
|
||||
id: 'link1',
|
||||
type: 'link',
|
||||
url: 'https://example.com',
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
},
|
||||
{
|
||||
id: 'text2',
|
||||
type: 'text',
|
||||
text: '- [x] Another text task',
|
||||
x: 100,
|
||||
y: 300,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const tasks = parser.parseCanvasFile(canvasContent, 'test.canvas');
|
||||
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0].content).toBe('Text node task');
|
||||
expect(tasks[0].completed).toBe(false);
|
||||
expect(tasks[1].content).toBe('Another text task');
|
||||
expect(tasks[1].completed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidCanvasContent', () => {
|
||||
it('should validate correct canvas JSON', () => {
|
||||
const validCanvas = JSON.stringify({
|
||||
nodes: [],
|
||||
edges: []
|
||||
});
|
||||
|
||||
expect(CanvasParser.isValidCanvasContent(validCanvas)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid JSON', () => {
|
||||
const invalidJson = '{ invalid }';
|
||||
expect(CanvasParser.isValidCanvasContent(invalidJson)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject JSON without required properties', () => {
|
||||
const missingNodes = JSON.stringify({ edges: [] });
|
||||
const missingEdges = JSON.stringify({ nodes: [] });
|
||||
|
||||
expect(CanvasParser.isValidCanvasContent(missingNodes)).toBe(false);
|
||||
expect(CanvasParser.isValidCanvasContent(missingEdges)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextOnly', () => {
|
||||
it('should extract text content from all text nodes', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
type: 'text',
|
||||
text: 'First text node',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData,
|
||||
{
|
||||
id: 'file1',
|
||||
type: 'file',
|
||||
file: 'some-file.md',
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
type: 'text',
|
||||
text: 'Second text node',
|
||||
x: 100,
|
||||
y: 300,
|
||||
width: 200,
|
||||
height: 100
|
||||
} as CanvasTextData
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const textContent = parser.extractTextOnly(canvasContent);
|
||||
|
||||
expect(textContent).toBe('First text node\n\nSecond text node');
|
||||
});
|
||||
|
||||
it('should handle canvas with no text nodes', () => {
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'file1',
|
||||
type: 'file',
|
||||
file: 'some-file.md',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasContent = JSON.stringify(canvasData);
|
||||
const textContent = parser.extractTextOnly(canvasContent);
|
||||
|
||||
expect(textContent).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
823
src/__tests__/CanvasTaskUpdater.test.ts
Normal file
823
src/__tests__/CanvasTaskUpdater.test.ts
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
/**
|
||||
* Tests for Canvas task updater functionality
|
||||
*/
|
||||
|
||||
import { CanvasTaskUpdater } from '../utils/parsing/CanvasTaskUpdater';
|
||||
import { Task, CanvasTaskMetadata } from '../types/task';
|
||||
import { CanvasData } from '../types/canvas';
|
||||
|
||||
// Mock Vault and TFile
|
||||
class MockVault {
|
||||
private files: Map<string, string> = new Map();
|
||||
|
||||
getFileByPath(path: string) {
|
||||
if (this.files.has(path)) {
|
||||
return new MockTFile(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async read(file: MockTFile): Promise<string> {
|
||||
return this.files.get(file.path) || '';
|
||||
}
|
||||
|
||||
async modify(file: MockTFile, content: string): Promise<void> {
|
||||
this.files.set(file.path, content);
|
||||
}
|
||||
|
||||
setFileContent(path: string, content: string): void {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
|
||||
getFileContent(path: string): string | undefined {
|
||||
return this.files.get(path);
|
||||
}
|
||||
}
|
||||
|
||||
class MockTFile {
|
||||
constructor(public path: string) {}
|
||||
|
||||
// Add properties to make it compatible with TFile interface
|
||||
get name() {
|
||||
return this.path.split('/').pop() || '';
|
||||
}
|
||||
|
||||
get extension() {
|
||||
return this.path.split('.').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Plugin
|
||||
class MockPlugin {
|
||||
settings = {
|
||||
preferMetadataFormat: 'tasks' as const,
|
||||
projectTagPrefix: {
|
||||
tasks: 'project',
|
||||
dataview: 'project'
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: '@',
|
||||
dataview: 'context'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('CanvasTaskUpdater', () => {
|
||||
let mockVault: MockVault;
|
||||
let mockPlugin: MockPlugin;
|
||||
let updater: CanvasTaskUpdater;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = new MockVault();
|
||||
mockPlugin = new MockPlugin();
|
||||
updater = new CanvasTaskUpdater(mockVault as any, mockPlugin as any);
|
||||
});
|
||||
|
||||
describe('isCanvasTask', () => {
|
||||
it('should identify Canvas tasks correctly', () => {
|
||||
const canvasTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Test task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const markdownTask: Task = {
|
||||
id: 'test-2',
|
||||
content: 'Test task',
|
||||
filePath: 'test.md',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
expect(CanvasTaskUpdater.isCanvasTask(canvasTask)).toBe(true);
|
||||
expect(CanvasTaskUpdater.isCanvasTask(markdownTask)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCanvasTask', () => {
|
||||
const sampleCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'text',
|
||||
text: '# Test Node\n\n- [ ] Original task\n- [x] Completed task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
type: 'text',
|
||||
text: '# Another Node\n\n- [ ] Another task',
|
||||
x: 400,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault.setFileContent('test.canvas', JSON.stringify(sampleCanvasData, null, 2));
|
||||
});
|
||||
|
||||
it('should update task status in Canvas file', async () => {
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Original task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Original task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x'
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Verify the Canvas file was updated
|
||||
const updatedContent = mockVault.getFileContent('test.canvas');
|
||||
expect(updatedContent).toBeDefined();
|
||||
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'node-1');
|
||||
expect(updatedNode.text).toContain('- [x] Original task');
|
||||
});
|
||||
|
||||
it('should handle missing Canvas file', async () => {
|
||||
const task: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Test task',
|
||||
filePath: 'nonexistent.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(task, task);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Canvas file not found');
|
||||
});
|
||||
|
||||
it('should handle missing Canvas node ID', async () => {
|
||||
const task: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Test task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas'
|
||||
// Missing canvasNodeId
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(task, task);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('does not have a Canvas node ID');
|
||||
});
|
||||
|
||||
it('should handle missing Canvas node', async () => {
|
||||
const task: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Test task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'nonexistent-node'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(task, task);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Canvas text node not found');
|
||||
});
|
||||
|
||||
it('should handle invalid Canvas JSON', async () => {
|
||||
mockVault.setFileContent('test.canvas', 'invalid json');
|
||||
|
||||
const task: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Test task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(task, task);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to parse Canvas JSON');
|
||||
});
|
||||
|
||||
it('should handle task not found in node', async () => {
|
||||
const task: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Nonexistent task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Nonexistent task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(task, task);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Task not found in Canvas text node');
|
||||
});
|
||||
|
||||
it('should update multiple different task statuses', async () => {
|
||||
// Test updating from incomplete to complete
|
||||
const task1: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Original task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Original task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask1 = { ...task1, completed: true, status: 'x' };
|
||||
await updater.updateCanvasTask(task1, updatedTask1);
|
||||
|
||||
// Test updating from complete to incomplete
|
||||
const task2: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-2',
|
||||
content: 'Completed task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: true,
|
||||
status: 'x',
|
||||
originalMarkdown: '- [x] Completed task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask2 = { ...task2, completed: false, status: ' ' };
|
||||
const result = await updater.updateCanvasTask(task2, updatedTask2);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify both updates
|
||||
const updatedContent = mockVault.getFileContent('test.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'node-1');
|
||||
|
||||
expect(updatedNode.text).toContain('- [x] Original task');
|
||||
expect(updatedNode.text).toContain('- [ ] Completed task');
|
||||
});
|
||||
|
||||
it('should update task with due date metadata', async () => {
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Task with due date',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Task with due date',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const dueDate = new Date('2024-12-25').getTime();
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
content: 'Task with due date',
|
||||
metadata: {
|
||||
...originalTask.metadata,
|
||||
dueDate: dueDate
|
||||
}
|
||||
};
|
||||
|
||||
// First, add the task to the canvas
|
||||
const canvasData = JSON.parse(mockVault.getFileContent('test.canvas')!);
|
||||
canvasData.nodes[0].text = '# Test Node\n\n- [ ] Task with due date\n- [x] Completed task';
|
||||
mockVault.setFileContent('test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Verify the Canvas file was updated with due date
|
||||
const updatedContent = mockVault.getFileContent('test.canvas');
|
||||
expect(updatedContent).toBeDefined();
|
||||
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'node-1');
|
||||
expect(updatedNode.text).toContain('Task with due date 📅 2024-12-25');
|
||||
});
|
||||
|
||||
it('should update task with priority and tags', async () => {
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Task with metadata',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Task with metadata',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
content: 'Task with metadata',
|
||||
metadata: {
|
||||
...originalTask.metadata,
|
||||
priority: 4,
|
||||
tags: ['#important', '#work'],
|
||||
project: 'TestProject',
|
||||
context: 'office'
|
||||
}
|
||||
};
|
||||
|
||||
// First, add the task to the canvas
|
||||
const canvasData = JSON.parse(mockVault.getFileContent('test.canvas')!);
|
||||
canvasData.nodes[0].text = '# Test Node\n\n- [ ] Task with metadata\n- [x] Completed task';
|
||||
mockVault.setFileContent('test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Verify the Canvas file was updated with metadata
|
||||
const updatedContent = mockVault.getFileContent('test.canvas');
|
||||
expect(updatedContent).toBeDefined();
|
||||
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'node-1');
|
||||
|
||||
// Check for tags, project, context, and priority
|
||||
expect(updatedNode.text).toContain('#important');
|
||||
expect(updatedNode.text).toContain('#work');
|
||||
expect(updatedNode.text).toContain('#project/TestProject');
|
||||
expect(updatedNode.text).toContain('@office');
|
||||
expect(updatedNode.text).toContain('⏫'); // High priority emoji
|
||||
});
|
||||
|
||||
it('should handle multiple tasks with same name correctly', async () => {
|
||||
// Create a complex canvas with multiple same-named tasks
|
||||
const complexCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'project-planning',
|
||||
type: 'text',
|
||||
text: '# Project Planning\n\n## Initial Setup\n- [ ] Define project scope\n- [ ] Set up development environment\n- [x] Create project repository\n- [ ] Configure CI/CD pipeline\n\n## Research Phase\n- [ ] Market research\n- [ ] Competitor analysis\n- [ ] Technology stack evaluation',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '1'
|
||||
},
|
||||
{
|
||||
id: 'development-tasks',
|
||||
type: 'text',
|
||||
text: '# Development Tasks\n\n## Frontend\n- [ ] Design user interface mockups\n- [ ] Implement responsive layout\n- [ ] Add user authentication\n- [ ] Create dashboard components\n\n## Backend\n- [ ] Set up database schema\n- [ ] Implement REST API\n- [ ] Add data validation\n- [ ] Configure security middleware',
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '2'
|
||||
},
|
||||
{
|
||||
id: 'testing-qa',
|
||||
type: 'text',
|
||||
text: '# Testing & QA\n\n## Unit Testing\n- [ ] Write component tests\n- [ ] API endpoint tests\n- [ ] Database integration tests\n\n## Integration Testing\n- [ ] End-to-end testing\n- [ ] Performance testing\n- [ ] Security testing\n\n## Quality Assurance\n- [ ] Code review process\n- [ ] Documentation review\n- [ ] User acceptance testing',
|
||||
x: 100,
|
||||
y: 420,
|
||||
width: 350,
|
||||
height: 300,
|
||||
color: '3'
|
||||
},
|
||||
{
|
||||
id: 'deployment',
|
||||
type: 'text',
|
||||
text: '# Deployment & Maintenance\n\n## Deployment\n- [ ] Set up production environment\n- [ ] Configure monitoring\n- [ ] Deploy application\n- [ ] Verify deployment\n\n## Post-Launch\n- [ ] Monitor performance\n- [ ] Gather user feedback\n- [ ] Bug fixes and improvements\n- [ ] Feature enhancements',
|
||||
x: 500,
|
||||
y: 420,
|
||||
width: 350,
|
||||
height: 300,
|
||||
color: '4'
|
||||
},
|
||||
{
|
||||
id: 'meeting-notes',
|
||||
type: 'text',
|
||||
text: '# Meeting Notes\n\n## Daily Standup - 2024-01-15\n- [x] Discussed project progress\n- [ ] Review sprint goals\n- [ ] Address blockers\n\n## Sprint Planning\n- [ ] Estimate story points\n- [ ] Assign tasks to team members\n- [ ] Set sprint timeline',
|
||||
x: 900,
|
||||
y: 280,
|
||||
width: 300,
|
||||
height: 250,
|
||||
color: '5'
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge1',
|
||||
fromNode: 'project-planning',
|
||||
toNode: 'development-tasks',
|
||||
fromSide: 'right',
|
||||
toSide: 'left'
|
||||
},
|
||||
{
|
||||
id: 'edge2',
|
||||
fromNode: 'development-tasks',
|
||||
toNode: 'testing-qa',
|
||||
fromSide: 'bottom',
|
||||
toSide: 'top'
|
||||
},
|
||||
{
|
||||
id: 'edge3',
|
||||
fromNode: 'testing-qa',
|
||||
toNode: 'deployment',
|
||||
fromSide: 'right',
|
||||
toSide: 'left'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockVault.setFileContent('complex.canvas', JSON.stringify(complexCanvasData, null, 2));
|
||||
|
||||
// Test updating a specific task in the first node
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Define project scope',
|
||||
filePath: 'complex.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Define project scope',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'project-planning'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x'
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify only the correct task was updated
|
||||
const updatedContent = mockVault.getFileContent('complex.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'project-planning');
|
||||
|
||||
expect(updatedNode.text).toContain('- [x] Define project scope');
|
||||
expect(updatedNode.text).toContain('- [ ] Set up development environment'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [x] Create project repository'); // Should remain unchanged
|
||||
});
|
||||
|
||||
it('should handle tasks with identical names and metadata correctly', async () => {
|
||||
// Create a canvas with multiple identical task names but different metadata
|
||||
const identicalTasksCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'identical-tasks-node',
|
||||
type: 'text',
|
||||
text: '# Tasks with Same Names\n\n- [ ] Task In Canvas 📅 2025-06-21\n- [ ] Task In Canvas 🛫 2025-06-21\n- [ ] Task In Canvas #SO/旅行\n- [ ] Task In Canvas\n- [ ] A new day',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '1'
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.setFileContent('identical-tasks.canvas', JSON.stringify(identicalTasksCanvasData, null, 2));
|
||||
|
||||
// Test updating the third task (with #SO/旅行 tag)
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Task In Canvas',
|
||||
filePath: 'identical-tasks.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Task In Canvas #SO/旅行',
|
||||
metadata: {
|
||||
tags: ['#SO/旅行'],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'identical-tasks-node'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x'
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify only the correct task was updated
|
||||
const updatedContent = mockVault.getFileContent('identical-tasks.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'identical-tasks-node');
|
||||
|
||||
// Check that only the task with #SO/旅行 was updated
|
||||
expect(updatedNode.text).toContain('- [ ] Task In Canvas 📅 2025-06-21'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [ ] Task In Canvas 🛫 2025-06-21'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [x] Task In Canvas #SO/旅行'); // Should be updated
|
||||
expect(updatedNode.text).toContain('- [ ] Task In Canvas'); // Should remain unchanged (plain task)
|
||||
expect(updatedNode.text).toContain('- [ ] A new day'); // Should remain unchanged
|
||||
});
|
||||
|
||||
it('should properly remove and add metadata without affecting other tasks', async () => {
|
||||
// Create a canvas with tasks that have metadata to be removed/modified
|
||||
const metadataTestCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'metadata-test-node',
|
||||
type: 'text',
|
||||
text: '# Metadata Test\n\n- [ ] Task with due date 📅 2025-06-21\n- [ ] Task with start date 🛫 2025-06-20\n- [ ] Task with priority ⏫\n- [ ] Task with multiple metadata 📅 2025-06-21 🛫 2025-06-20 #important @work',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '1'
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.setFileContent('metadata-test.canvas', JSON.stringify(metadataTestCanvasData, null, 2));
|
||||
|
||||
// Test removing due date from the first task
|
||||
// Note: lineMatchesTask only compares content, not the full originalMarkdown
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Task with due date 📅 2025-06-21', // This should match the line content after removing checkbox
|
||||
filePath: 'metadata-test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Task with due date 📅 2025-06-21',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'metadata-test-node',
|
||||
dueDate: new Date('2025-06-21').getTime()
|
||||
}
|
||||
};
|
||||
|
||||
// Update task to remove due date
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
content: 'Task with due date', // Remove metadata from content
|
||||
metadata: {
|
||||
...originalTask.metadata,
|
||||
dueDate: undefined // Remove due date
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify metadata was properly removed from the correct task only
|
||||
const updatedContent = mockVault.getFileContent('metadata-test.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'metadata-test-node');
|
||||
|
||||
// Check that due date was removed from the first task
|
||||
expect(updatedNode.text).toContain('- [ ] Task with due date\n'); // Should not contain 📅 2025-06-21
|
||||
expect(updatedNode.text).not.toMatch(/- \[ \] Task with due date 📅 2025-06-21/);
|
||||
|
||||
// Check that other tasks remain unchanged
|
||||
expect(updatedNode.text).toContain('- [ ] Task with start date 🛫 2025-06-20');
|
||||
expect(updatedNode.text).toContain('- [ ] Task with priority ⏫');
|
||||
expect(updatedNode.text).toContain('- [ ] Task with multiple metadata 📅 2025-06-21 🛫 2025-06-20 #important @work');
|
||||
});
|
||||
|
||||
it('should handle updating tasks when multiple tasks have similar content', async () => {
|
||||
// Test edge case where task content is very similar
|
||||
const similarTasksCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'similar-tasks-node',
|
||||
type: 'text',
|
||||
text: '# Similar Tasks\n\n- [ ] Review code\n- [ ] Review code changes\n- [ ] Review code for bugs\n- [x] Review code completely\n- [ ] Review',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '1'
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.setFileContent('similar-tasks.canvas', JSON.stringify(similarTasksCanvasData, null, 2));
|
||||
|
||||
// Test updating the exact "Review code" task (first one)
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Review code',
|
||||
filePath: 'similar-tasks.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Review code',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'similar-tasks-node'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x'
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify only the exact match was updated
|
||||
const updatedContent = mockVault.getFileContent('similar-tasks.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'similar-tasks-node');
|
||||
|
||||
// Check that only the exact "Review code" task was updated
|
||||
expect(updatedNode.text).toContain('- [x] Review code\n'); // First task should be updated
|
||||
expect(updatedNode.text).toContain('- [ ] Review code changes'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [ ] Review code for bugs'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [x] Review code completely'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [ ] Review'); // Should remain unchanged
|
||||
});
|
||||
|
||||
it('should handle canvas file monitoring and updates correctly', async () => {
|
||||
// This test simulates the file monitoring scenario
|
||||
// Create a canvas with tasks
|
||||
const monitoringTestCanvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'monitoring-test-node',
|
||||
type: 'text',
|
||||
text: '# File Monitoring Test\n\n- [ ] Initial task\n- [ ] Another task\n- [x] Completed task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 350,
|
||||
height: 280,
|
||||
color: '1'
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.setFileContent('monitoring-test.canvas', JSON.stringify(monitoringTestCanvasData, null, 2));
|
||||
|
||||
// Simulate updating a task as if it was modified in the Canvas file
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-1',
|
||||
content: 'Initial task',
|
||||
filePath: 'monitoring-test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Initial task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'monitoring-test-node'
|
||||
}
|
||||
};
|
||||
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x',
|
||||
metadata: {
|
||||
...originalTask.metadata,
|
||||
completedDate: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
const result = await updater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify the Canvas file was updated correctly
|
||||
const updatedContent = mockVault.getFileContent('monitoring-test.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'monitoring-test-node');
|
||||
|
||||
// Check that the task was updated and completion date was added
|
||||
expect(updatedNode.text).toContain('- [x] Initial task');
|
||||
expect(updatedNode.text).toContain('- [ ] Another task'); // Should remain unchanged
|
||||
expect(updatedNode.text).toContain('- [x] Completed task'); // Should remain unchanged
|
||||
|
||||
// Verify completion date was added
|
||||
const today = new Date();
|
||||
const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
expect(updatedNode.text).toContain(`✅ ${expectedDate}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
502
src/__tests__/FileMetadataTaskParser.test.ts
Normal file
502
src/__tests__/FileMetadataTaskParser.test.ts
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
/**
|
||||
* Tests for FileMetadataTaskParser and FileMetadataTaskUpdater
|
||||
*/
|
||||
|
||||
import { FileMetadataTaskParser } from "../utils/workers/FileMetadataTaskParser";
|
||||
import { FileMetadataTaskUpdater } from "../utils/workers/FileMetadataTaskUpdater";
|
||||
import { FileParsingConfiguration } from "../common/setting-definition";
|
||||
import { StandardFileTaskMetadata, Task } from "../types/task";
|
||||
|
||||
describe("FileMetadataTaskParser", () => {
|
||||
let parser: FileMetadataTaskParser;
|
||||
let config: FileParsingConfiguration;
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
enableFileMetadataParsing: true,
|
||||
metadataFieldsToParseAsTasks: [
|
||||
"dueDate",
|
||||
"todo",
|
||||
"complete",
|
||||
"task",
|
||||
],
|
||||
enableTagBasedTaskParsing: true,
|
||||
tagsToParseAsTasks: ["#todo", "#task", "#action", "#due"],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
};
|
||||
parser = new FileMetadataTaskParser(config);
|
||||
});
|
||||
|
||||
describe("parseFileForTasks", () => {
|
||||
it("should parse tasks from file metadata", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File\n\nSome content here.";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
dueDate: "2024-01-15",
|
||||
todo: true,
|
||||
priority: 2,
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(2); // One for dueDate, one for todo
|
||||
|
||||
// Check dueDate task
|
||||
const dueDateTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceField ===
|
||||
"dueDate"
|
||||
);
|
||||
expect(dueDateTask).toBeDefined();
|
||||
expect(dueDateTask?.content).toBe("Test Task");
|
||||
expect(dueDateTask?.status).toBe(" "); // Due dates are typically incomplete
|
||||
expect(dueDateTask?.metadata.dueDate).toBeDefined();
|
||||
|
||||
// Check todo task
|
||||
const todoTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceField ===
|
||||
"todo"
|
||||
);
|
||||
expect(todoTask).toBeDefined();
|
||||
expect(todoTask?.content).toBe("Test Task");
|
||||
expect(todoTask?.status).toBe("x"); // todo: true should be completed
|
||||
});
|
||||
|
||||
it("should parse tasks from file tags", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File\n\nSome content here.";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
tag: "#todo",
|
||||
position: {
|
||||
start: { line: 0, col: 0 },
|
||||
end: { line: 0, col: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "#action",
|
||||
position: {
|
||||
start: { line: 1, col: 0 },
|
||||
end: { line: 1, col: 7 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache as any
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(2); // One for #todo, one for #action
|
||||
|
||||
// Check todo tag task
|
||||
const todoTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceTag ===
|
||||
"#todo"
|
||||
);
|
||||
expect(todoTask).toBeDefined();
|
||||
expect(todoTask?.content).toBe("Test Task");
|
||||
expect(todoTask?.status).toBe(" "); // Default status
|
||||
|
||||
// Check action tag task
|
||||
const actionTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceTag ===
|
||||
"#action"
|
||||
);
|
||||
expect(actionTask).toBeDefined();
|
||||
expect(actionTask?.content).toBe("Test Task");
|
||||
expect(actionTask?.status).toBe(" "); // Default status
|
||||
});
|
||||
|
||||
it("should use filename when title metadata is not available", () => {
|
||||
const filePath = "My Important Task.md";
|
||||
const fileContent = "# Test File\n\nSome content here.";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
dueDate: "2024-01-15",
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].content).toBe("My Important Task"); // Filename without extension
|
||||
});
|
||||
|
||||
it("should handle different task status determination", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
complete: true,
|
||||
todo: false,
|
||||
dueDate: "2024-01-15",
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(3); // complete, todo, dueDate
|
||||
|
||||
// Check complete task
|
||||
const completeTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceField ===
|
||||
"complete"
|
||||
);
|
||||
expect(completeTask?.status).toBe("x"); // complete: true should be completed
|
||||
|
||||
// Check todo task
|
||||
const todoTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceField ===
|
||||
"todo"
|
||||
);
|
||||
expect(todoTask?.status).toBe(" "); // todo: false should be incomplete
|
||||
|
||||
// Check dueDate task
|
||||
const dueDateTask = result.tasks.find(
|
||||
(t) =>
|
||||
(t.metadata as StandardFileTaskMetadata).sourceField ===
|
||||
"dueDate"
|
||||
);
|
||||
expect(dueDateTask?.status).toBe(" "); // Due dates are typically incomplete
|
||||
});
|
||||
|
||||
it("should not create tasks when parsing is disabled", () => {
|
||||
const disabledConfig: FileParsingConfiguration = {
|
||||
enableFileMetadataParsing: false,
|
||||
metadataFieldsToParseAsTasks: ["dueDate", "todo"],
|
||||
enableTagBasedTaskParsing: false,
|
||||
tagsToParseAsTasks: ["#todo"],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
};
|
||||
const disabledParser = new FileMetadataTaskParser(disabledConfig);
|
||||
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
dueDate: "2024-01-15",
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
tag: "#todo",
|
||||
position: {
|
||||
start: { line: 0, col: 0 },
|
||||
end: { line: 0, col: 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = disabledParser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache as any
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(0); // No tasks should be created
|
||||
});
|
||||
|
||||
it("should extract additional metadata correctly", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
dueDate: "2024-01-15",
|
||||
priority: "high",
|
||||
project: "Work Project",
|
||||
context: "office",
|
||||
area: "development",
|
||||
tags: ["important", "urgent"],
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
|
||||
const task = result.tasks[0];
|
||||
expect(task.metadata.priority).toBe(3); // "high" should be converted to 3
|
||||
expect(task.metadata.project).toBe("Work Project");
|
||||
expect(task.metadata.context).toBe("office");
|
||||
expect(task.metadata.area).toBe("development");
|
||||
expect(task.metadata.tags).toEqual(["important", "urgent"]);
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = null; // This should not cause a crash
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache as any
|
||||
);
|
||||
|
||||
expect(result.tasks).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0); // Should handle gracefully without errors
|
||||
});
|
||||
});
|
||||
|
||||
describe("date parsing", () => {
|
||||
it("should parse various date formats", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
dueDate: "2024-01-15",
|
||||
startDate: new Date("2024-01-10"),
|
||||
scheduledDate: 1705276800000, // Timestamp
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.tasks).toHaveLength(1); // Only dueDate is in the configured fields
|
||||
const task = result.tasks[0];
|
||||
expect(task.metadata.dueDate).toBeDefined();
|
||||
expect(typeof task.metadata.dueDate).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority parsing", () => {
|
||||
it("should parse various priority formats", () => {
|
||||
const filePath = "test.md";
|
||||
const fileContent = "# Test File";
|
||||
const fileCache = {
|
||||
frontmatter: {
|
||||
title: "Test Task",
|
||||
dueDate: "2024-01-15",
|
||||
priority: "medium",
|
||||
},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const result = parser.parseFileForTasks(
|
||||
filePath,
|
||||
fileContent,
|
||||
fileCache
|
||||
);
|
||||
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
const task = result.tasks[0];
|
||||
expect(task.metadata.priority).toBe(2); // "medium" should be converted to 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileMetadataTaskUpdater", () => {
|
||||
let updater: FileMetadataTaskUpdater;
|
||||
let config: FileParsingConfiguration;
|
||||
let mockApp: any;
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
enableFileMetadataParsing: true,
|
||||
metadataFieldsToParseAsTasks: [
|
||||
"dueDate",
|
||||
"todo",
|
||||
"complete",
|
||||
"task",
|
||||
],
|
||||
enableTagBasedTaskParsing: true,
|
||||
tagsToParseAsTasks: ["#todo", "#task", "#action", "#due"],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
};
|
||||
|
||||
// Mock Obsidian App
|
||||
mockApp = {
|
||||
vault: {
|
||||
getFileByPath: jest.fn(),
|
||||
read: jest.fn(),
|
||||
rename: jest.fn(),
|
||||
},
|
||||
fileManager: {
|
||||
processFrontMatter: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
updater = new FileMetadataTaskUpdater(mockApp, config);
|
||||
});
|
||||
|
||||
describe("isFileMetadataTask", () => {
|
||||
it("should identify file metadata tasks", () => {
|
||||
const metadataTask: Task = {
|
||||
id: "test.md-metadata-dueDate",
|
||||
content: "Test Task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test Task",
|
||||
metadata: {
|
||||
source: "file-metadata",
|
||||
sourceField: "dueDate",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
} as StandardFileTaskMetadata,
|
||||
};
|
||||
|
||||
const tagTask: Task = {
|
||||
id: "test.md-tag-todo",
|
||||
content: "Test Task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test Task",
|
||||
metadata: {
|
||||
source: "file-tag",
|
||||
sourceTag: "#todo",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
} as StandardFileTaskMetadata,
|
||||
};
|
||||
|
||||
const regularTask: Task = {
|
||||
id: "test.md-L5",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 5,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(updater.isFileMetadataTask(metadataTask)).toBe(true);
|
||||
expect(updater.isFileMetadataTask(tagTask)).toBe(true);
|
||||
expect(updater.isFileMetadataTask(regularTask)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFileMetadataTask", () => {
|
||||
it("should handle file not found error", async () => {
|
||||
const originalTask: Task = {
|
||||
id: "nonexistent.md-metadata-dueDate",
|
||||
content: "Test Task",
|
||||
filePath: "nonexistent.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test Task",
|
||||
metadata: {
|
||||
source: "file-metadata",
|
||||
sourceField: "dueDate",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
} as StandardFileTaskMetadata,
|
||||
};
|
||||
|
||||
const updatedTask = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: "x",
|
||||
};
|
||||
|
||||
mockApp.vault.getFileByPath.mockReturnValue(null);
|
||||
|
||||
const result = await updater.updateFileMetadataTask(
|
||||
originalTask,
|
||||
updatedTask
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("File not found");
|
||||
});
|
||||
|
||||
it("should handle non-file-metadata tasks", async () => {
|
||||
const regularTask: Task = {
|
||||
id: "test.md-L5",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 5,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const updatedTask = {
|
||||
...regularTask,
|
||||
completed: true,
|
||||
status: "x",
|
||||
};
|
||||
|
||||
const result = await updater.updateFileMetadataTask(
|
||||
regularTask,
|
||||
updatedTask
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not a file metadata task");
|
||||
});
|
||||
});
|
||||
});
|
||||
571
src/__tests__/ProjectConfigManager.test.ts
Normal file
571
src/__tests__/ProjectConfigManager.test.ts
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
/**
|
||||
* ProjectConfigManager Tests
|
||||
*
|
||||
* Tests for project configuration management including:
|
||||
* - Path-based project mappings
|
||||
* - Metadata-based project detection
|
||||
* - Config file-based project detection
|
||||
* - Metadata field mappings
|
||||
* - Default project naming strategies
|
||||
*/
|
||||
|
||||
import {
|
||||
ProjectConfigManager,
|
||||
ProjectConfigManagerOptions,
|
||||
MetadataMapping,
|
||||
ProjectNamingStrategy,
|
||||
} from "../utils/ProjectConfigManager";
|
||||
import { TgProject } from "../types/task";
|
||||
|
||||
// Mock Obsidian types
|
||||
class MockTFile {
|
||||
constructor(
|
||||
public path: string,
|
||||
public name: string,
|
||||
public parent: MockTFolder | null = null
|
||||
) {
|
||||
this.stat = { mtime: Date.now() };
|
||||
}
|
||||
stat: { mtime: number };
|
||||
}
|
||||
|
||||
class MockTFolder {
|
||||
constructor(
|
||||
public path: string,
|
||||
public name: string,
|
||||
public parent: MockTFolder | null = null,
|
||||
public children: (MockTFile | MockTFolder)[] = []
|
||||
) {}
|
||||
}
|
||||
|
||||
class MockVault {
|
||||
private files = new Map<string, MockTFile>();
|
||||
private fileContents = new Map<string, string>();
|
||||
|
||||
addFile(path: string, content: string): MockTFile {
|
||||
const fileName = path.split('/').pop() || '';
|
||||
const file = new MockTFile(path, fileName);
|
||||
this.files.set(path, file);
|
||||
this.fileContents.set(path, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
addFolder(path: string): MockTFolder {
|
||||
const folderName = path.split('/').pop() || '';
|
||||
return new MockTFolder(path, folderName);
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: string): MockTFile | null {
|
||||
return this.files.get(path) || null;
|
||||
}
|
||||
|
||||
async read(file: MockTFile): Promise<string> {
|
||||
return this.fileContents.get(file.path) || '';
|
||||
}
|
||||
}
|
||||
|
||||
class MockMetadataCache {
|
||||
private cache = new Map<string, any>();
|
||||
|
||||
setFileMetadata(path: string, metadata: any): void {
|
||||
this.cache.set(path, { frontmatter: metadata });
|
||||
}
|
||||
|
||||
getFileCache(file: MockTFile): any {
|
||||
return this.cache.get(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProjectConfigManager", () => {
|
||||
let vault: MockVault = new MockVault();
|
||||
let metadataCache: MockMetadataCache = new MockMetadataCache();
|
||||
let manager: ProjectConfigManager;
|
||||
|
||||
const defaultOptions: ProjectConfigManagerOptions = {
|
||||
vault: vault as any,
|
||||
metadataCache: metadataCache as any,
|
||||
configFileName: "project.md",
|
||||
searchRecursively: true,
|
||||
metadataKey: "project",
|
||||
pathMappings: [],
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vault = new MockVault();
|
||||
metadataCache = new MockMetadataCache();
|
||||
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
vault: vault as any,
|
||||
metadataCache: metadataCache as any,
|
||||
};
|
||||
|
||||
manager = new ProjectConfigManager(options);
|
||||
});
|
||||
|
||||
describe("Path-based project mapping", () => {
|
||||
it("should detect project from path mappings", async () => {
|
||||
const pathMappings = [
|
||||
{
|
||||
pathPattern: "Projects/Work",
|
||||
projectName: "Work Project",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
pathPattern: "Personal",
|
||||
projectName: "Personal Project",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ pathMappings });
|
||||
|
||||
const workProject = await manager.determineTgProject("Projects/Work/task.md");
|
||||
expect(workProject).toEqual({
|
||||
type: "path",
|
||||
name: "Work Project",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
const personalProject = await manager.determineTgProject("Personal/notes.md");
|
||||
expect(personalProject).toEqual({
|
||||
type: "path",
|
||||
name: "Personal Project",
|
||||
source: "Personal",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore disabled path mappings", async () => {
|
||||
const pathMappings = [
|
||||
{
|
||||
pathPattern: "Projects/Work",
|
||||
projectName: "Work Project",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ pathMappings });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/Work/task.md");
|
||||
expect(project).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should support wildcard patterns", async () => {
|
||||
const pathMappings = [
|
||||
{
|
||||
pathPattern: "Projects/*",
|
||||
projectName: "Any Project",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ pathMappings });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/SomeProject/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "path",
|
||||
name: "Any Project",
|
||||
source: "Projects/*",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata-based project detection", () => {
|
||||
it("should detect project from file frontmatter", async () => {
|
||||
vault.addFile("test.md", "# Test file");
|
||||
metadataCache.setFileMetadata("test.md", { project: "My Project" });
|
||||
|
||||
const project = await manager.determineTgProject("test.md");
|
||||
expect(project).toEqual({
|
||||
type: "metadata",
|
||||
name: "My Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use custom metadata key", async () => {
|
||||
manager.updateOptions({ metadataKey: "proj" });
|
||||
vault.addFile("test.md", "# Test file");
|
||||
metadataCache.setFileMetadata("test.md", { proj: "Custom Project" });
|
||||
|
||||
const project = await manager.determineTgProject("test.md");
|
||||
expect(project).toEqual({
|
||||
type: "metadata",
|
||||
name: "Custom Project",
|
||||
source: "proj",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle missing files gracefully", async () => {
|
||||
const project = await manager.determineTgProject("nonexistent.md");
|
||||
expect(project).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config file-based project detection", () => {
|
||||
it("should detect project from config file", async () => {
|
||||
// Create a project config file
|
||||
vault.addFile("Projects/project.md", `---
|
||||
project: Config Project
|
||||
---
|
||||
|
||||
# Project Configuration
|
||||
`);
|
||||
|
||||
// Mock the folder structure
|
||||
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
// Set metadata for config file
|
||||
metadataCache.setFileMetadata("Projects/project.md", { project: "Config Project" });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "config",
|
||||
name: "Config Project",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse project from config file content", async () => {
|
||||
const configContent = `
|
||||
# Project Configuration
|
||||
|
||||
project: Content Project
|
||||
description: A project defined in content
|
||||
`;
|
||||
vault.addFile("Projects/project.md", configContent);
|
||||
|
||||
// Mock folder structure
|
||||
const file = vault.addFile("Projects/task.md", "- [ ] Test task");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "config",
|
||||
name: "Content Project",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metadata mappings", () => {
|
||||
it("should apply metadata mappings", async () => {
|
||||
const metadataMappings: MetadataMapping[] = [
|
||||
{
|
||||
sourceKey: "proj",
|
||||
targetKey: "project",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
sourceKey: "due_date",
|
||||
targetKey: "due",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ metadataMappings });
|
||||
|
||||
vault.addFile("test.md", "# Test file");
|
||||
metadataCache.setFileMetadata("test.md", {
|
||||
proj: "Mapped Project",
|
||||
due_date: "2024-01-01",
|
||||
other: "value",
|
||||
});
|
||||
|
||||
const enhancedMetadata = await manager.getEnhancedMetadata("test.md");
|
||||
expect(enhancedMetadata).toEqual({
|
||||
proj: "Mapped Project",
|
||||
due_date: "2024-01-01",
|
||||
other: "value",
|
||||
project: "Mapped Project",
|
||||
due: 1704038400000,
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore disabled mappings", async () => {
|
||||
const metadataMappings: MetadataMapping[] = [
|
||||
{
|
||||
sourceKey: "proj",
|
||||
targetKey: "project",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ metadataMappings });
|
||||
|
||||
vault.addFile("test.md", "# Test file");
|
||||
metadataCache.setFileMetadata("test.md", { proj: "Should Not Map" });
|
||||
|
||||
const enhancedMetadata = await manager.getEnhancedMetadata("test.md");
|
||||
expect(enhancedMetadata).toEqual({
|
||||
proj: "Should Not Map",
|
||||
});
|
||||
expect(enhancedMetadata.project).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default project naming", () => {
|
||||
it("should use filename as project name", async () => {
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/my-document.md");
|
||||
expect(project).toEqual({
|
||||
type: "default",
|
||||
name: "my-document",
|
||||
source: "filename",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use filename without stripping extension", async () => {
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "filename",
|
||||
stripExtension: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/my-document.md");
|
||||
expect(project).toEqual({
|
||||
type: "default",
|
||||
name: "my-document.md",
|
||||
source: "filename",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use folder name as project name", async () => {
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "foldername",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/WorkFolder/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "default",
|
||||
name: "WorkFolder",
|
||||
source: "foldername",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use metadata value as project name", async () => {
|
||||
vault.addFile("anywhere/task.md", "# Test file");
|
||||
metadataCache.setFileMetadata("anywhere/task.md", { "project-name": "Global Project" });
|
||||
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "metadata",
|
||||
metadataKey: "project-name",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
const project = await manager.determineTgProject("anywhere/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "default",
|
||||
name: "Global Project",
|
||||
source: "metadata",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not apply default naming when disabled", async () => {
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/my-document.md");
|
||||
expect(project).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Priority order", () => {
|
||||
it("should prioritize path mappings over metadata", async () => {
|
||||
const pathMappings = [
|
||||
{
|
||||
pathPattern: "Projects",
|
||||
projectName: "Path Project",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
manager.updateOptions({ pathMappings });
|
||||
|
||||
vault.addFile("Projects/task.md", "# Test file");
|
||||
metadataCache.setFileMetadata("Projects/task.md", { project: "Metadata Project" });
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "path",
|
||||
name: "Path Project",
|
||||
source: "Projects",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritize metadata over config file", async () => {
|
||||
vault.addFile("Projects/task.md", "# Test file");
|
||||
vault.addFile("Projects/project.md", "project: Config Project");
|
||||
metadataCache.setFileMetadata("Projects/task.md", { project: "Metadata Project" });
|
||||
|
||||
// Mock folder structure
|
||||
const file = vault.getAbstractFileByPath("Projects/task.md");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (file && configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "metadata",
|
||||
name: "Metadata Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritize config file over default naming", async () => {
|
||||
const defaultProjectNaming: ProjectNamingStrategy = {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
manager.updateOptions({ defaultProjectNaming });
|
||||
|
||||
vault.addFile("Projects/task.md", "# Test file");
|
||||
vault.addFile("Projects/project.md", "project: Config Project");
|
||||
|
||||
// Mock folder structure
|
||||
const file = vault.getAbstractFileByPath("Projects/task.md");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (file && configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toEqual({
|
||||
type: "config",
|
||||
name: "Config Project",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache project config data", async () => {
|
||||
vault.addFile("Projects/project.md", "project: Cached Project");
|
||||
vault.addFile("Projects/task.md", "# Test file");
|
||||
|
||||
// Mock folder structure
|
||||
const file = vault.getAbstractFileByPath("Projects/task.md");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (file && configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
// First call should read and cache
|
||||
const project1 = await manager.determineTgProject("Projects/task.md");
|
||||
|
||||
// Second call should use cache
|
||||
const project2 = await manager.determineTgProject("Projects/task.md");
|
||||
|
||||
expect(project1).toEqual(project2);
|
||||
expect(project1?.name).toBe("Cached Project");
|
||||
});
|
||||
|
||||
it("should clear cache when options change", async () => {
|
||||
vault.addFile("test.md", "# Test file");
|
||||
metadataCache.setFileMetadata("test.md", { project: "Original Project" });
|
||||
|
||||
const project1 = await manager.determineTgProject("test.md");
|
||||
expect(project1?.name).toBe("Original Project");
|
||||
|
||||
// Change metadata key
|
||||
manager.updateOptions({ metadataKey: "proj" });
|
||||
metadataCache.setFileMetadata("test.md", { proj: "New Project" });
|
||||
|
||||
const project2 = await manager.determineTgProject("test.md");
|
||||
expect(project2?.name).toBe("New Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle file access errors gracefully", async () => {
|
||||
// Mock vault that throws errors
|
||||
const errorVault = {
|
||||
getAbstractFileByPath: () => { throw new Error("File access error"); },
|
||||
};
|
||||
|
||||
const errorManager = new ProjectConfigManager({
|
||||
...defaultOptions,
|
||||
vault: errorVault as any,
|
||||
});
|
||||
|
||||
const project = await errorManager.determineTgProject("test.md");
|
||||
expect(project).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle malformed config files gracefully", async () => {
|
||||
vault.addFile("Projects/project.md", "Invalid content without proper format");
|
||||
vault.addFile("Projects/task.md", "# Test file");
|
||||
|
||||
// Mock folder structure
|
||||
const file = vault.getAbstractFileByPath("Projects/task.md");
|
||||
const folder = vault.addFolder("Projects");
|
||||
const configFile = vault.getAbstractFileByPath("Projects/project.md");
|
||||
if (file && configFile) {
|
||||
folder.children.push(configFile);
|
||||
file.parent = folder;
|
||||
}
|
||||
|
||||
const project = await manager.determineTgProject("Projects/task.md");
|
||||
expect(project).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
1295
src/__tests__/TaskParsingService.integration.test.ts
Normal file
1295
src/__tests__/TaskParsingService.integration.test.ts
Normal file
File diff suppressed because it is too large
Load diff
324
src/__tests__/TimelineCanvasIntegration.test.ts
Normal file
324
src/__tests__/TimelineCanvasIntegration.test.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Timeline Canvas Integration Tests
|
||||
*
|
||||
* Tests to verify that Canvas task completion works correctly in the Timeline feature
|
||||
*/
|
||||
|
||||
import { Task, CanvasTaskMetadata } from "../types/task";
|
||||
import { CanvasTaskUpdater } from "../utils/parsing/CanvasTaskUpdater";
|
||||
import { CanvasData } from "../types/canvas";
|
||||
|
||||
// Mock Vault and TFile (same as CanvasTaskUpdater.test.ts)
|
||||
class MockVault {
|
||||
private files: Map<string, string> = new Map();
|
||||
|
||||
getFileByPath(path: string) {
|
||||
if (this.files.has(path)) {
|
||||
return new MockTFile(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async read(file: MockTFile): Promise<string> {
|
||||
return this.files.get(file.path) || '';
|
||||
}
|
||||
|
||||
async modify(file: MockTFile, content: string): Promise<void> {
|
||||
this.files.set(file.path, content);
|
||||
}
|
||||
|
||||
setFileContent(path: string, content: string): void {
|
||||
this.files.set(path, content);
|
||||
}
|
||||
|
||||
getFileContent(path: string): string | undefined {
|
||||
return this.files.get(path);
|
||||
}
|
||||
|
||||
createFile(path: string, content: string): MockTFile {
|
||||
this.files.set(path, content);
|
||||
return new MockTFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
class MockTFile {
|
||||
constructor(public path: string) {}
|
||||
|
||||
get name() {
|
||||
return this.path.split('/').pop() || '';
|
||||
}
|
||||
|
||||
get extension() {
|
||||
return this.path.split('.').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Plugin (same as CanvasTaskUpdater.test.ts)
|
||||
class MockPlugin {
|
||||
settings = {
|
||||
preferMetadataFormat: 'tasks' as const,
|
||||
projectTagPrefix: {
|
||||
tasks: 'project',
|
||||
dataview: 'project'
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: '@',
|
||||
dataview: 'context'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('Timeline Canvas Integration', () => {
|
||||
let mockPlugin: MockPlugin;
|
||||
let mockVault: MockVault;
|
||||
let canvasTaskUpdater: CanvasTaskUpdater;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlugin = new MockPlugin();
|
||||
mockVault = new MockVault();
|
||||
|
||||
// Initialize CanvasTaskUpdater
|
||||
canvasTaskUpdater = new CanvasTaskUpdater(mockVault as any, mockPlugin as any);
|
||||
});
|
||||
|
||||
describe('Canvas Task Identification', () => {
|
||||
it('should correctly identify Canvas tasks', () => {
|
||||
const canvasTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'canvas-task-1',
|
||||
content: 'Canvas task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Canvas task',
|
||||
metadata: {
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'node-1',
|
||||
tags: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
const markdownTask: Task = {
|
||||
id: 'markdown-task-1',
|
||||
content: 'Markdown task',
|
||||
filePath: 'test.md',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Markdown task',
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
expect(CanvasTaskUpdater.isCanvasTask(canvasTask)).toBe(true);
|
||||
expect(CanvasTaskUpdater.isCanvasTask(markdownTask)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas Task Completion in Timeline', () => {
|
||||
it('should successfully complete Canvas tasks through TaskManager', async () => {
|
||||
// Create a Canvas file with a task
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'test-node',
|
||||
type: 'text',
|
||||
text: '# Test Canvas\n\n- [ ] Test Canvas task',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
const canvasFile = mockVault.createFile('test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
// Create a Canvas task
|
||||
const originalTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'test-canvas-task',
|
||||
content: 'Test Canvas task',
|
||||
filePath: 'test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Test Canvas task',
|
||||
metadata: {
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'test-node',
|
||||
tags: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
// Create updated task (completed)
|
||||
const updatedTask: Task<CanvasTaskMetadata> = {
|
||||
...originalTask,
|
||||
completed: true,
|
||||
status: 'x',
|
||||
metadata: {
|
||||
...originalTask.metadata,
|
||||
completedDate: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
// Test Canvas task update directly
|
||||
const result = await canvasTaskUpdater.updateCanvasTask(originalTask, updatedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify the Canvas file was updated
|
||||
const updatedContent = mockVault.getFileContent('test.canvas');
|
||||
expect(updatedContent).toBeDefined();
|
||||
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'test-node');
|
||||
|
||||
expect(updatedNode.text).toContain('- [x] Test Canvas task');
|
||||
});
|
||||
|
||||
it('should handle Canvas task completion through Timeline toggleTaskCompletion flow', async () => {
|
||||
// This test simulates the Timeline's task completion flow
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'timeline-test-node',
|
||||
type: 'text',
|
||||
text: '# Timeline Test\n\n- [ ] Timeline Canvas task 📅 2025-01-15',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.createFile('timeline-test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
const canvasTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'timeline-canvas-task',
|
||||
content: 'Timeline Canvas task', // Content should be clean task text
|
||||
filePath: 'timeline-test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Timeline Canvas task 📅 2025-01-15', // This should match the Canvas file exactly
|
||||
metadata: {
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'timeline-test-node',
|
||||
dueDate: new Date('2025-01-15').getTime(),
|
||||
tags: [],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate the Timeline's toggleTaskCompletion logic
|
||||
const updatedTask = { ...canvasTask, completed: !canvasTask.completed };
|
||||
|
||||
if (updatedTask.completed) {
|
||||
updatedTask.metadata.completedDate = Date.now();
|
||||
updatedTask.status = 'x';
|
||||
}
|
||||
|
||||
// Test that CanvasTaskUpdater.isCanvasTask correctly identifies this task
|
||||
expect(CanvasTaskUpdater.isCanvasTask(canvasTask)).toBe(true);
|
||||
|
||||
// Test the Canvas task update
|
||||
const result = await canvasTaskUpdater.updateCanvasTask(canvasTask, updatedTask);
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Canvas task update failed:', result.error);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify the Canvas file was updated correctly
|
||||
const updatedContent = mockVault.getFileContent('timeline-test.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'timeline-test-node');
|
||||
|
||||
expect(updatedNode.text).toContain('- [x] Timeline Canvas task');
|
||||
expect(updatedNode.text).toContain('📅 2025-01-15');
|
||||
|
||||
// Should also contain completion date
|
||||
const today = new Date();
|
||||
const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
expect(updatedNode.text).toContain(`✅ ${expectedDate}`);
|
||||
});
|
||||
|
||||
it('should handle Canvas task completion with originalMarkdown matching', async () => {
|
||||
// Test the improved originalMarkdown matching logic
|
||||
const canvasData: CanvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'matching-test-node',
|
||||
type: 'text',
|
||||
text: '# Matching Test\n\n- [ ] Task with complex metadata #project/test @home ⏫ 📅 2025-01-20',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 250
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
mockVault.createFile('matching-test.canvas', JSON.stringify(canvasData, null, 2));
|
||||
|
||||
const complexTask: Task<CanvasTaskMetadata> = {
|
||||
id: 'complex-canvas-task',
|
||||
content: 'Task with complex metadata',
|
||||
filePath: 'matching-test.canvas',
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: ' ',
|
||||
originalMarkdown: '- [ ] Task with complex metadata #project/test @home ⏫ 📅 2025-01-20',
|
||||
metadata: {
|
||||
sourceType: 'canvas',
|
||||
canvasNodeId: 'matching-test-node',
|
||||
dueDate: new Date('2025-01-20').getTime(),
|
||||
priority: 4,
|
||||
project: 'test',
|
||||
context: 'home',
|
||||
tags: ['#project/test'],
|
||||
children: []
|
||||
}
|
||||
};
|
||||
|
||||
// Complete the task
|
||||
const completedTask = {
|
||||
...complexTask,
|
||||
completed: true,
|
||||
status: 'x',
|
||||
metadata: {
|
||||
...complexTask.metadata,
|
||||
completedDate: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
const result = await canvasTaskUpdater.updateCanvasTask(complexTask, completedTask);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify the task was found and updated correctly
|
||||
const updatedContent = mockVault.getFileContent('matching-test.canvas');
|
||||
const updatedCanvasData = JSON.parse(updatedContent!);
|
||||
const updatedNode = updatedCanvasData.nodes.find((n: any) => n.id === 'matching-test-node');
|
||||
|
||||
expect(updatedNode.text).toContain('- [x] Task with complex metadata');
|
||||
expect(updatedNode.text).toContain('#project/test');
|
||||
expect(updatedNode.text).toContain('@home');
|
||||
expect(updatedNode.text).toContain('⏫');
|
||||
expect(updatedNode.text).toContain('📅 2025-01-20');
|
||||
|
||||
// Should contain completion date
|
||||
const today = new Date();
|
||||
const expectedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
expect(updatedNode.text).toContain(`✅ ${expectedDate}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
238
src/__tests__/badge-date-comparison.test.ts
Normal file
238
src/__tests__/badge-date-comparison.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Badge Date Comparison Tests
|
||||
* Tests to verify moment date comparison issues in badge rendering
|
||||
*/
|
||||
|
||||
import { moment } from "obsidian";
|
||||
|
||||
describe("Badge Date Comparison", () => {
|
||||
describe("Moment isSame comparison", () => {
|
||||
test("should compare dates correctly with different input types", () => {
|
||||
console.log("=== Date Comparison Debug ===");
|
||||
|
||||
// Test different ways dates might be created
|
||||
const targetDate = new Date("2024-01-15T00:00:00.000Z");
|
||||
const eventDate1 = new Date("2024-01-15T10:00:00.000Z"); // Same day, different time
|
||||
const eventDate2 = new Date("2024-01-15T00:00:00.000Z"); // Exact same
|
||||
const eventDate3 = new Date("2024-01-16T00:00:00.000Z"); // Different day
|
||||
|
||||
console.log("Target date:", targetDate.toISOString());
|
||||
console.log("Event date 1:", eventDate1.toISOString());
|
||||
console.log("Event date 2:", eventDate2.toISOString());
|
||||
console.log("Event date 3:", eventDate3.toISOString());
|
||||
|
||||
// Test moment comparison
|
||||
const targetMoment = moment(targetDate).startOf("day");
|
||||
const eventMoment1 = moment(eventDate1).startOf("day");
|
||||
const eventMoment2 = moment(eventDate2).startOf("day");
|
||||
const eventMoment3 = moment(eventDate3).startOf("day");
|
||||
|
||||
console.log("\nMoment objects:");
|
||||
console.log("Target moment:", targetMoment.format("YYYY-MM-DD"));
|
||||
console.log("Event moment 1:", eventMoment1.format("YYYY-MM-DD"));
|
||||
console.log("Event moment 2:", eventMoment2.format("YYYY-MM-DD"));
|
||||
console.log("Event moment 3:", eventMoment3.format("YYYY-MM-DD"));
|
||||
|
||||
// Test isSame
|
||||
const isSame1 = eventMoment1.isSame(targetMoment);
|
||||
const isSame2 = eventMoment2.isSame(targetMoment);
|
||||
const isSame3 = eventMoment3.isSame(targetMoment);
|
||||
|
||||
console.log("\nisSame results:");
|
||||
console.log("Event 1 isSame target:", isSame1);
|
||||
console.log("Event 2 isSame target:", isSame2);
|
||||
console.log("Event 3 isSame target:", isSame3);
|
||||
|
||||
// Test with 'day' unit
|
||||
const isSameDay1 = eventMoment1.isSame(targetMoment, "day");
|
||||
const isSameDay2 = eventMoment2.isSame(targetMoment, "day");
|
||||
const isSameDay3 = eventMoment3.isSame(targetMoment, "day");
|
||||
|
||||
console.log("\nisSame with 'day' unit:");
|
||||
console.log("Event 1 isSame target (day):", isSameDay1);
|
||||
console.log("Event 2 isSame target (day):", isSameDay2);
|
||||
console.log("Event 3 isSame target (day):", isSameDay3);
|
||||
|
||||
// Assertions
|
||||
expect(isSame1).toBe(true); // Same day after startOf('day')
|
||||
expect(isSame2).toBe(true); // Same day
|
||||
expect(isSame3).toBe(false); // Different day
|
||||
|
||||
expect(isSameDay1).toBe(true);
|
||||
expect(isSameDay2).toBe(true);
|
||||
expect(isSameDay3).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle timezone issues correctly", () => {
|
||||
console.log("\n=== Timezone Comparison Debug ===");
|
||||
|
||||
// Simulate potential timezone issues
|
||||
const utcDate = new Date("2024-01-15T10:00:00.000Z");
|
||||
const localDate = new Date("2024-01-15T10:00:00"); // No Z, local time
|
||||
|
||||
console.log("UTC date:", utcDate.toISOString());
|
||||
console.log("Local date:", localDate.toISOString());
|
||||
console.log("UTC date local string:", utcDate.toString());
|
||||
console.log("Local date local string:", localDate.toString());
|
||||
|
||||
const utcMoment = moment(utcDate).startOf("day");
|
||||
const localMoment = moment(localDate).startOf("day");
|
||||
const targetMoment = moment(new Date("2024-01-15")).startOf("day");
|
||||
|
||||
console.log("\nMoment formats:");
|
||||
console.log("UTC moment:", utcMoment.format("YYYY-MM-DD HH:mm:ss"));
|
||||
console.log(
|
||||
"Local moment:",
|
||||
localMoment.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"Target moment:",
|
||||
targetMoment.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
|
||||
const utcSame = utcMoment.isSame(targetMoment, "day");
|
||||
const localSame = localMoment.isSame(targetMoment, "day");
|
||||
|
||||
console.log("\nComparison results:");
|
||||
console.log("UTC same as target:", utcSame);
|
||||
console.log("Local same as target:", localSame);
|
||||
|
||||
// Both should be true for the same day
|
||||
expect(utcSame).toBe(true);
|
||||
expect(localSame).toBe(true);
|
||||
});
|
||||
|
||||
test("should debug actual badge comparison scenario", () => {
|
||||
console.log("\n=== Actual Badge Scenario Debug ===");
|
||||
|
||||
// Simulate the actual scenario from getBadgeEventsForDate
|
||||
const inputDate = new Date("2024-01-15"); // Date passed to getBadgeEventsForDate
|
||||
const icsEventDate = new Date("2024-01-15T10:00:00Z"); // ICS event dtstart
|
||||
|
||||
console.log("Input date:", inputDate.toISOString());
|
||||
console.log("ICS event date:", icsEventDate.toISOString());
|
||||
|
||||
// This is what happens in getBadgeEventsForDate
|
||||
const targetDate = moment(inputDate).startOf("day");
|
||||
const eventDate = moment(icsEventDate).startOf("day");
|
||||
|
||||
console.log(
|
||||
"Target date moment:",
|
||||
targetDate.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"Event date moment:",
|
||||
eventDate.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
|
||||
// Test the comparison
|
||||
const isSameResult = eventDate.isSame(targetDate);
|
||||
const isSameDayResult = eventDate.isSame(targetDate, "day");
|
||||
|
||||
console.log("isSame result:", isSameResult);
|
||||
console.log("isSame with 'day' unit:", isSameDayResult);
|
||||
|
||||
// Debug internal values
|
||||
console.log("\nInternal debug:");
|
||||
console.log("Target date valueOf:", targetDate.valueOf());
|
||||
console.log("Event date valueOf:", eventDate.valueOf());
|
||||
console.log("Target date _date:", (targetDate as any)._date);
|
||||
console.log("Event date _date:", (eventDate as any)._date);
|
||||
|
||||
// This should be true
|
||||
expect(isSameResult).toBe(true);
|
||||
expect(isSameDayResult).toBe(true);
|
||||
});
|
||||
|
||||
test("should test edge cases with different date formats", () => {
|
||||
console.log("\n=== Edge Cases Debug ===");
|
||||
|
||||
// Test various date input formats
|
||||
const testCases = [
|
||||
{
|
||||
name: "String date",
|
||||
target: "2024-01-15",
|
||||
event: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
{
|
||||
name: "Timestamp",
|
||||
target: new Date("2024-01-15").getTime(),
|
||||
event: new Date("2024-01-15T10:00:00Z").getTime(),
|
||||
},
|
||||
{
|
||||
name: "Date objects",
|
||||
target: new Date("2024-01-15"),
|
||||
event: new Date("2024-01-15T10:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach((testCase, index) => {
|
||||
console.log(`\nTest case ${index + 1}: ${testCase.name}`);
|
||||
console.log("Target:", testCase.target);
|
||||
console.log("Event:", testCase.event);
|
||||
|
||||
const targetMoment = moment(testCase.target).startOf("day");
|
||||
const eventMoment = moment(testCase.event).startOf("day");
|
||||
|
||||
console.log(
|
||||
"Target moment:",
|
||||
targetMoment.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
console.log(
|
||||
"Event moment:",
|
||||
eventMoment.format("YYYY-MM-DD HH:mm:ss")
|
||||
);
|
||||
|
||||
const isSame = eventMoment.isSame(targetMoment);
|
||||
console.log("isSame result:", isSame);
|
||||
|
||||
expect(isSame).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should identify the specific issue with mock moment", () => {
|
||||
console.log("\n=== Mock Moment Issue Debug ===");
|
||||
|
||||
// Test the exact scenario that might be failing
|
||||
const date = new Date("2024-01-15");
|
||||
const icsEventDtstart = new Date("2024-01-15T10:00:00Z");
|
||||
|
||||
console.log("Original dates:");
|
||||
console.log("Date:", date);
|
||||
console.log("ICS event dtstart:", icsEventDtstart);
|
||||
|
||||
// Create moments like in the actual code
|
||||
const targetDate = moment(date).startOf("day");
|
||||
const eventDate = moment(icsEventDtstart).startOf("day");
|
||||
|
||||
console.log("\nMoment objects:");
|
||||
console.log("Target date:", targetDate);
|
||||
console.log("Event date:", eventDate);
|
||||
|
||||
// Check internal structure
|
||||
console.log("\nInternal structure:");
|
||||
console.log("Target date _date:", (targetDate as any)._date);
|
||||
console.log("Event date _date:", (eventDate as any)._date);
|
||||
|
||||
// Test the comparison
|
||||
const result = eventDate.isSame(targetDate);
|
||||
console.log("\nComparison result:", result);
|
||||
|
||||
// Manual comparison for debugging
|
||||
if ((targetDate as any)._date && (eventDate as any)._date) {
|
||||
const targetDateStr = (targetDate as any)._date
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
const eventDateStr = (eventDate as any)._date
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
console.log("Manual string comparison:");
|
||||
console.log("Target date string:", targetDateStr);
|
||||
console.log("Event date string:", eventDateStr);
|
||||
console.log("Strings equal:", targetDateStr === eventDateStr);
|
||||
}
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
251
src/__tests__/badge-date-parsing.test.ts
Normal file
251
src/__tests__/badge-date-parsing.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Badge Date Parsing Tests
|
||||
* Tests to verify date parsing issues in getBadgeEventsForDate
|
||||
*/
|
||||
|
||||
import { moment } from "obsidian";
|
||||
|
||||
describe("Badge Date Parsing", () => {
|
||||
describe("Date input parsing", () => {
|
||||
test("should handle different date input formats", () => {
|
||||
console.log("=== Date Input Parsing Debug ===");
|
||||
|
||||
// Test the actual data you provided
|
||||
const realIcsEventDate = "2020-01-31T16:00:00.000Z";
|
||||
const realQueryDate =
|
||||
"Sun Jul 06 2025 00:00:00 GMT+0800 (China Standard Time)";
|
||||
|
||||
console.log("Real ICS event date:", realIcsEventDate);
|
||||
console.log("Real query date:", realQueryDate);
|
||||
|
||||
// Parse the ICS event date
|
||||
const icsDate = new Date(realIcsEventDate);
|
||||
console.log("Parsed ICS date:", icsDate);
|
||||
console.log("ICS date ISO:", icsDate.toISOString());
|
||||
|
||||
// Parse the query date (this might be the problem)
|
||||
const queryDate = new Date(realQueryDate);
|
||||
console.log("Parsed query date:", queryDate);
|
||||
console.log("Query date ISO:", queryDate.toISOString());
|
||||
console.log("Query date valid:", !isNaN(queryDate.getTime()));
|
||||
|
||||
// Test moment parsing
|
||||
const icsMoment = moment(icsDate).startOf("day");
|
||||
const queryMoment = moment(queryDate).startOf("day");
|
||||
|
||||
console.log("ICS moment:", icsMoment.format("YYYY-MM-DD"));
|
||||
console.log("Query moment:", queryMoment.format("YYYY-MM-DD"));
|
||||
|
||||
// These should obviously not match (2020 vs 2025)
|
||||
const shouldMatch = icsMoment.isSame(queryMoment);
|
||||
console.log("Should match (obviously no):", shouldMatch);
|
||||
|
||||
expect(shouldMatch).toBe(false); // They shouldn't match
|
||||
});
|
||||
|
||||
test("should test various date string formats that might be passed", () => {
|
||||
console.log("\n=== Various Date Format Tests ===");
|
||||
|
||||
const testFormats = [
|
||||
"Sun Jul 06 2025 00:00:00 GMT+0800 (China Standard Time)",
|
||||
"2025-07-06",
|
||||
"2025-07-06T00:00:00.000Z",
|
||||
"2025-07-06T00:00:00+08:00",
|
||||
new Date("2025-07-06"),
|
||||
new Date("2025-07-06T00:00:00.000Z"),
|
||||
1751731200000, // timestamp for 2025-07-06
|
||||
];
|
||||
|
||||
testFormats.forEach((dateInput, index) => {
|
||||
console.log(
|
||||
`\nTest ${index + 1}: ${typeof dateInput} - ${dateInput}`
|
||||
);
|
||||
|
||||
try {
|
||||
const parsedDate = new Date(dateInput as any);
|
||||
console.log(" Parsed:", parsedDate);
|
||||
console.log(" ISO:", parsedDate.toISOString());
|
||||
console.log(" Valid:", !isNaN(parsedDate.getTime()));
|
||||
|
||||
const momentDate = moment(parsedDate).startOf("day");
|
||||
console.log(" Moment:", momentDate.format("YYYY-MM-DD"));
|
||||
} catch (error) {
|
||||
console.log(" Error:", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should test the specific scenario with real data", () => {
|
||||
console.log("\n=== Real Scenario Test ===");
|
||||
|
||||
// Your real ICS task data
|
||||
const icsTask = {
|
||||
id: "ics-ics-1749431536058-nzoj4uxtw-2020-02-01-lc@infinet.github.io",
|
||||
content: "正月初八|六 2-1",
|
||||
filePath: "ics://农历",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] 正月初八|六 2-1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: 1580486400000,
|
||||
dueDate: 1580486400000,
|
||||
scheduledDate: 1580486400000,
|
||||
project: "农历",
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: {
|
||||
uid: "2020-02-01-lc@infinet.github.io",
|
||||
summary: "正月初八|六 2-1",
|
||||
dtstart: "2020-01-31T16:00:00.000Z",
|
||||
allDay: true,
|
||||
source: {
|
||||
id: "ics-1749431536058-nzoj4uxtw",
|
||||
name: "农历",
|
||||
url: "https://lwlsw.github.io/Chinese-Lunar-Calendar-ics/chinese_lunar_my.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
},
|
||||
dtend: "2020-01-31T16:00:00.000Z",
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: "农历",
|
||||
id: "ics-1749431536058-nzoj4uxtw",
|
||||
},
|
||||
};
|
||||
|
||||
// Test different ways the date might be passed to getBadgeEventsForDate
|
||||
const possibleDateInputs = [
|
||||
"Sun Jul 06 2025 00:00:00 GMT+0800 (China Standard Time)",
|
||||
new Date(
|
||||
"Sun Jul 06 2025 00:00:00 GMT+0800 (China Standard Time)"
|
||||
),
|
||||
new Date("2025-07-06"),
|
||||
new Date(2025, 6, 6), // Month is 0-based
|
||||
];
|
||||
|
||||
possibleDateInputs.forEach((dateInput, index) => {
|
||||
console.log(`\nDate input ${index + 1}:`, dateInput);
|
||||
|
||||
// Simulate getBadgeEventsForDate logic
|
||||
const targetDate = moment(dateInput as any).startOf("day");
|
||||
const eventDate = moment(icsTask.icsEvent.dtstart).startOf(
|
||||
"day"
|
||||
);
|
||||
|
||||
console.log(
|
||||
" Target date moment:",
|
||||
targetDate.format("YYYY-MM-DD")
|
||||
);
|
||||
console.log(
|
||||
" Event date moment:",
|
||||
eventDate.format("YYYY-MM-DD")
|
||||
);
|
||||
console.log(" Dates match:", eventDate.isSame(targetDate));
|
||||
console.log(
|
||||
" String comparison:",
|
||||
eventDate.format("YYYY-MM-DD") ===
|
||||
targetDate.format("YYYY-MM-DD")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should test if the issue is with dtstart being a string", () => {
|
||||
console.log("\n=== dtstart String vs Date Test ===");
|
||||
|
||||
const dtstartString = "2020-01-31T16:00:00.000Z";
|
||||
const dtstartDate = new Date(dtstartString);
|
||||
|
||||
console.log("dtstart as string:", dtstartString);
|
||||
console.log("dtstart as Date:", dtstartDate);
|
||||
|
||||
// Test moment parsing of both
|
||||
const momentFromString = moment(dtstartString).startOf("day");
|
||||
const momentFromDate = moment(dtstartDate).startOf("day");
|
||||
|
||||
console.log(
|
||||
"Moment from string:",
|
||||
momentFromString.format("YYYY-MM-DD")
|
||||
);
|
||||
console.log(
|
||||
"Moment from Date:",
|
||||
momentFromDate.format("YYYY-MM-DD")
|
||||
);
|
||||
console.log(
|
||||
"Both moments equal:",
|
||||
momentFromString.isSame(momentFromDate)
|
||||
);
|
||||
|
||||
// Test with a target date
|
||||
const targetDate = moment(new Date("2020-01-31")).startOf("day");
|
||||
console.log("Target date:", targetDate.format("YYYY-MM-DD"));
|
||||
console.log(
|
||||
"String moment matches target:",
|
||||
momentFromString.isSame(targetDate)
|
||||
);
|
||||
console.log(
|
||||
"Date moment matches target:",
|
||||
momentFromDate.isSame(targetDate)
|
||||
);
|
||||
});
|
||||
|
||||
test("should test timezone handling", () => {
|
||||
console.log("\n=== Timezone Handling Test ===");
|
||||
|
||||
// The ICS event is at 16:00 UTC on 2020-01-31
|
||||
// In China timezone (GMT+8), this would be 00:00 on 2020-02-01
|
||||
const dtstartUTC = "2020-01-31T16:00:00.000Z";
|
||||
const date = new Date(dtstartUTC);
|
||||
|
||||
console.log("UTC date:", date.toISOString());
|
||||
console.log("Local date string:", date.toString());
|
||||
console.log("Local date only:", date.toDateString());
|
||||
|
||||
// Test different ways to get the local date
|
||||
const localYear = date.getFullYear();
|
||||
const localMonth = date.getMonth();
|
||||
const localDay = date.getDate();
|
||||
|
||||
console.log(
|
||||
"Local components:",
|
||||
localYear,
|
||||
localMonth + 1,
|
||||
localDay
|
||||
);
|
||||
|
||||
// Create a local date for comparison
|
||||
const localDate = new Date(localYear, localMonth, localDay);
|
||||
console.log("Reconstructed local date:", localDate);
|
||||
|
||||
// Test moment with different approaches
|
||||
const momentUTC = moment(date).startOf("day");
|
||||
const momentLocal = moment(localDate).startOf("day");
|
||||
|
||||
console.log(
|
||||
"Moment UTC startOf day:",
|
||||
momentUTC.format("YYYY-MM-DD")
|
||||
);
|
||||
console.log(
|
||||
"Moment local startOf day:",
|
||||
momentLocal.format("YYYY-MM-DD")
|
||||
);
|
||||
|
||||
// Test if they match a target date of 2020-02-01 (local date)
|
||||
const targetDate = moment(new Date("2020-02-01")).startOf("day");
|
||||
console.log("Target date:", targetDate.format("YYYY-MM-DD"));
|
||||
console.log("UTC moment matches:", momentUTC.isSame(targetDate));
|
||||
console.log(
|
||||
"Local moment matches:",
|
||||
momentLocal.isSame(targetDate)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
490
src/__tests__/badge-debug-helper.test.ts
Normal file
490
src/__tests__/badge-debug-helper.test.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/**
|
||||
* Badge Debug Helper
|
||||
* This test helps debug why badges might not be showing in the calendar view
|
||||
*/
|
||||
|
||||
import { moment } from "obsidian";
|
||||
import { IcsTask, IcsEvent, IcsSource } from "../types/ics";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
describe("Badge Debug Helper", () => {
|
||||
// Helper function to create a realistic badge task
|
||||
function createBadgeTask(
|
||||
sourceId: string,
|
||||
sourceName: string,
|
||||
eventDate: Date,
|
||||
color: string = "#ff6b6b"
|
||||
): IcsTask {
|
||||
const badgeSource: IcsSource = {
|
||||
id: sourceId,
|
||||
name: sourceName,
|
||||
url: `https://example.com/${sourceId}.ics`,
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: color,
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: `${sourceId}-event-${eventDate.getTime()}`,
|
||||
summary: `Event from ${sourceName}`,
|
||||
description: "This should appear as a badge",
|
||||
dtstart: eventDate,
|
||||
dtend: new Date(eventDate.getTime() + 60 * 60 * 1000), // 1 hour later
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: `ics-${sourceId}-${badgeEvent.uid}`,
|
||||
content: badgeEvent.summary,
|
||||
filePath: `ics://${sourceName}`,
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: `- [ ] ${badgeEvent.summary}`,
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
return badgeTask;
|
||||
}
|
||||
|
||||
// Debug function to check if a task should show as badge
|
||||
function debugTaskBadgeStatus(task: Task): {
|
||||
isIcsTask: boolean;
|
||||
hasIcsEvent: boolean;
|
||||
hasSource: boolean;
|
||||
showType: string | undefined;
|
||||
shouldShowAsBadge: boolean;
|
||||
debugInfo: string[];
|
||||
} {
|
||||
const debugInfo: string[] = [];
|
||||
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
debugInfo.push(`Is ICS task: ${isIcsTask}`);
|
||||
|
||||
if (!isIcsTask) {
|
||||
debugInfo.push("❌ Not an ICS task - will not show as badge");
|
||||
return {
|
||||
isIcsTask,
|
||||
hasIcsEvent: false,
|
||||
hasSource: false,
|
||||
showType: undefined,
|
||||
shouldShowAsBadge: false,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const icsTask = task as IcsTask;
|
||||
const hasIcsEvent = !!icsTask.icsEvent;
|
||||
debugInfo.push(`Has ICS event: ${hasIcsEvent}`);
|
||||
|
||||
if (!hasIcsEvent) {
|
||||
debugInfo.push("❌ No ICS event - will not show as badge");
|
||||
return {
|
||||
isIcsTask,
|
||||
hasIcsEvent,
|
||||
hasSource: false,
|
||||
showType: undefined,
|
||||
shouldShowAsBadge: false,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const hasSource = !!icsTask.icsEvent.source;
|
||||
debugInfo.push(`Has source: ${hasSource}`);
|
||||
|
||||
if (!hasSource) {
|
||||
debugInfo.push("❌ No source - will not show as badge");
|
||||
return {
|
||||
isIcsTask,
|
||||
hasIcsEvent,
|
||||
hasSource,
|
||||
showType: undefined,
|
||||
shouldShowAsBadge: false,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const showType = icsTask.icsEvent.source.showType;
|
||||
debugInfo.push(`Show type: ${showType}`);
|
||||
|
||||
const shouldShowAsBadge = showType === "badge";
|
||||
if (shouldShowAsBadge) {
|
||||
debugInfo.push("✅ Should show as badge");
|
||||
} else {
|
||||
debugInfo.push(
|
||||
`❌ Show type is "${showType}", not "badge" - will not show as badge`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isIcsTask,
|
||||
hasIcsEvent,
|
||||
hasSource,
|
||||
showType,
|
||||
shouldShowAsBadge,
|
||||
debugInfo,
|
||||
};
|
||||
}
|
||||
|
||||
// Debug function to simulate getBadgeEventsForDate
|
||||
function debugGetBadgeEventsForDate(
|
||||
tasks: Task[],
|
||||
date: Date
|
||||
): {
|
||||
targetDate: string;
|
||||
badgeEvents: any[];
|
||||
debugInfo: string[];
|
||||
taskAnalysis: any[];
|
||||
} {
|
||||
const debugInfo: string[] = [];
|
||||
const taskAnalysis: any[] = [];
|
||||
|
||||
const targetDate = moment(date).startOf("day");
|
||||
const targetDateStr = targetDate.format("YYYY-MM-DD");
|
||||
debugInfo.push(`Target date: ${targetDateStr}`);
|
||||
debugInfo.push(`Total tasks to check: ${tasks.length}`);
|
||||
|
||||
const badgeEvents: Map<
|
||||
string,
|
||||
{
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
const taskDebug = debugTaskBadgeStatus(task);
|
||||
taskAnalysis.push({
|
||||
taskIndex: index,
|
||||
taskId: task.id,
|
||||
...taskDebug,
|
||||
});
|
||||
|
||||
if (taskDebug.shouldShowAsBadge) {
|
||||
const icsTask = task as IcsTask;
|
||||
const eventDate = moment(icsTask.icsEvent.dtstart).startOf(
|
||||
"day"
|
||||
);
|
||||
const eventDateStr = eventDate.format("YYYY-MM-DD");
|
||||
|
||||
debugInfo.push(
|
||||
`Task ${index} (${task.id}): Event date ${eventDateStr}`
|
||||
);
|
||||
|
||||
if (eventDate.isSame(targetDate)) {
|
||||
debugInfo.push(`✅ Task ${index} matches target date`);
|
||||
|
||||
const sourceId = icsTask.icsEvent.source.id;
|
||||
const existing = badgeEvents.get(sourceId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
debugInfo.push(
|
||||
`📈 Incremented count for source ${sourceId} to ${existing.count}`
|
||||
);
|
||||
} else {
|
||||
badgeEvents.set(sourceId, {
|
||||
sourceId: sourceId,
|
||||
sourceName: icsTask.icsEvent.source.name,
|
||||
count: 1,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
});
|
||||
debugInfo.push(
|
||||
`🆕 Added new badge for source ${sourceId}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugInfo.push(
|
||||
`❌ Task ${index} date ${eventDateStr} does not match target ${targetDateStr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = Array.from(badgeEvents.values());
|
||||
debugInfo.push(`Final badge count: ${result.length}`);
|
||||
|
||||
return {
|
||||
targetDate: targetDateStr,
|
||||
badgeEvents: result,
|
||||
debugInfo,
|
||||
taskAnalysis,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Badge Detection Debug", () => {
|
||||
test("should debug badge task creation and detection", () => {
|
||||
console.log("=== Badge Debug Test ===");
|
||||
|
||||
// Create test tasks
|
||||
const badgeTask = createBadgeTask(
|
||||
"test-calendar",
|
||||
"Test Calendar",
|
||||
new Date("2024-01-15T10:00:00Z")
|
||||
);
|
||||
const regularTask: Task = {
|
||||
id: "regular-task-1",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
dueDate: new Date("2024-01-15").getTime(),
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const tasks = [badgeTask, regularTask];
|
||||
|
||||
console.log("\n--- Task Analysis ---");
|
||||
tasks.forEach((task, index) => {
|
||||
console.log(`\nTask ${index} (${task.id}):`);
|
||||
const debug = debugTaskBadgeStatus(task);
|
||||
debug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
});
|
||||
|
||||
console.log("\n--- Badge Events for 2024-01-15 ---");
|
||||
const targetDate = new Date("2024-01-15");
|
||||
const badgeDebug = debugGetBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
console.log(`Target date: ${badgeDebug.targetDate}`);
|
||||
badgeDebug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
|
||||
console.log("\nBadge events found:");
|
||||
badgeDebug.badgeEvents.forEach((badge, index) => {
|
||||
console.log(
|
||||
` Badge ${index}: ${badge.sourceName} (${badge.count} events, color: ${badge.color})`
|
||||
);
|
||||
});
|
||||
|
||||
// Assertions
|
||||
expect(badgeDebug.badgeEvents).toHaveLength(1);
|
||||
expect(badgeDebug.badgeEvents[0].sourceName).toBe("Test Calendar");
|
||||
expect(badgeDebug.badgeEvents[0].count).toBe(1);
|
||||
});
|
||||
|
||||
test("should debug multiple badge sources", () => {
|
||||
console.log("\n=== Multiple Badge Sources Debug ===");
|
||||
|
||||
const task1 = createBadgeTask(
|
||||
"calendar-1",
|
||||
"Calendar 1",
|
||||
new Date("2024-01-15T10:00:00Z"),
|
||||
"#ff6b6b"
|
||||
);
|
||||
const task2 = createBadgeTask(
|
||||
"calendar-2",
|
||||
"Calendar 2",
|
||||
new Date("2024-01-15T14:00:00Z"),
|
||||
"#4ecdc4"
|
||||
);
|
||||
const task3 = createBadgeTask(
|
||||
"calendar-1",
|
||||
"Calendar 1",
|
||||
new Date("2024-01-15T16:00:00Z"),
|
||||
"#ff6b6b"
|
||||
); // Same source as task1
|
||||
|
||||
const tasks = [task1, task2, task3];
|
||||
|
||||
console.log("\n--- Badge Events for 2024-01-15 ---");
|
||||
const badgeDebug = debugGetBadgeEventsForDate(
|
||||
tasks,
|
||||
new Date("2024-01-15")
|
||||
);
|
||||
|
||||
badgeDebug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
|
||||
console.log("\nFinal badge events:");
|
||||
badgeDebug.badgeEvents.forEach((badge, index) => {
|
||||
console.log(
|
||||
` Badge ${index}: ${badge.sourceName} (${badge.count} events, color: ${badge.color})`
|
||||
);
|
||||
});
|
||||
|
||||
// Assertions
|
||||
expect(badgeDebug.badgeEvents).toHaveLength(2);
|
||||
|
||||
const calendar1Badge = badgeDebug.badgeEvents.find(
|
||||
(b) => b.sourceId === "calendar-1"
|
||||
);
|
||||
const calendar2Badge = badgeDebug.badgeEvents.find(
|
||||
(b) => b.sourceId === "calendar-2"
|
||||
);
|
||||
|
||||
expect(calendar1Badge).toBeDefined();
|
||||
expect(calendar1Badge!.count).toBe(2); // Should aggregate
|
||||
|
||||
expect(calendar2Badge).toBeDefined();
|
||||
expect(calendar2Badge!.count).toBe(1);
|
||||
});
|
||||
|
||||
test("should debug date mismatch scenarios", () => {
|
||||
console.log("\n=== Date Mismatch Debug ===");
|
||||
|
||||
const task1 = createBadgeTask(
|
||||
"calendar-1",
|
||||
"Calendar 1",
|
||||
new Date("2024-01-15T10:00:00Z")
|
||||
);
|
||||
const task2 = createBadgeTask(
|
||||
"calendar-2",
|
||||
"Calendar 2",
|
||||
new Date("2024-01-16T10:00:00Z")
|
||||
); // Different date
|
||||
|
||||
const tasks = [task1, task2];
|
||||
|
||||
console.log("\n--- Badge Events for 2024-01-15 ---");
|
||||
const badgeDebug = debugGetBadgeEventsForDate(
|
||||
tasks,
|
||||
new Date("2024-01-15")
|
||||
);
|
||||
|
||||
badgeDebug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
|
||||
console.log("\nTask analysis:");
|
||||
badgeDebug.taskAnalysis.forEach((analysis, index) => {
|
||||
console.log(
|
||||
` Task ${index}: ${
|
||||
analysis.shouldShowAsBadge ? "✅" : "❌"
|
||||
} Should show as badge`
|
||||
);
|
||||
});
|
||||
|
||||
// Should only find one badge (for 2024-01-15)
|
||||
expect(badgeDebug.badgeEvents).toHaveLength(1);
|
||||
expect(badgeDebug.badgeEvents[0].sourceId).toBe("calendar-1");
|
||||
});
|
||||
|
||||
test("should debug common badge issues", () => {
|
||||
console.log("\n=== Common Badge Issues Debug ===");
|
||||
|
||||
// Issue 1: Wrong showType
|
||||
const wrongShowTypeSource: IcsSource = {
|
||||
id: "wrong-type",
|
||||
name: "Wrong Type Calendar",
|
||||
url: "https://example.com/wrong.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event", // Should be "badge"
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const wrongTypeTask = createBadgeTask(
|
||||
"wrong-type",
|
||||
"Wrong Type Calendar",
|
||||
new Date("2024-01-15T10:00:00Z")
|
||||
);
|
||||
(wrongTypeTask as any).icsEvent.source.showType = "event"; // Override to wrong type
|
||||
|
||||
// Issue 2: Missing source
|
||||
const missingSourceTask = createBadgeTask(
|
||||
"missing-source",
|
||||
"Missing Source Calendar",
|
||||
new Date("2024-01-15T10:00:00Z")
|
||||
);
|
||||
delete (missingSourceTask as any).icsEvent.source;
|
||||
|
||||
// Issue 3: Missing icsEvent
|
||||
const missingEventTask = createBadgeTask(
|
||||
"missing-event",
|
||||
"Missing Event Calendar",
|
||||
new Date("2024-01-15T10:00:00Z")
|
||||
);
|
||||
delete (missingEventTask as any).icsEvent;
|
||||
|
||||
// Issue 4: Not ICS task
|
||||
const notIcsTask: Task = {
|
||||
id: "not-ics",
|
||||
content: "Not ICS Task",
|
||||
filePath: "test.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Not ICS Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
dueDate: new Date("2024-01-15").getTime(),
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const tasks = [
|
||||
wrongTypeTask,
|
||||
missingSourceTask,
|
||||
missingEventTask,
|
||||
notIcsTask,
|
||||
];
|
||||
|
||||
console.log("\n--- Debugging Common Issues ---");
|
||||
tasks.forEach((task, index) => {
|
||||
console.log(`\nTask ${index} (${task.id}):`);
|
||||
const debug = debugTaskBadgeStatus(task);
|
||||
debug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
});
|
||||
|
||||
const badgeDebug = debugGetBadgeEventsForDate(
|
||||
tasks,
|
||||
new Date("2024-01-15")
|
||||
);
|
||||
|
||||
console.log("\nOverall result:");
|
||||
badgeDebug.debugInfo.forEach((info) => console.log(` ${info}`));
|
||||
|
||||
// None of these should produce badges
|
||||
expect(badgeDebug.badgeEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge Rendering Debug", () => {
|
||||
test("should provide debugging output for badge rendering", () => {
|
||||
console.log("\n=== Badge Rendering Debug Guide ===");
|
||||
console.log("\nTo debug badge rendering issues:");
|
||||
console.log(
|
||||
"1. Check if tasks are properly identified as badge events"
|
||||
);
|
||||
console.log("2. Verify getBadgeEventsForDate returns correct data");
|
||||
console.log("3. Ensure badge containers are created in DOM");
|
||||
console.log("4. Check if badge elements are properly styled");
|
||||
console.log("\nCommon issues:");
|
||||
console.log("- ICS source showType is not 'badge'");
|
||||
console.log("- Event date doesn't match target date");
|
||||
console.log("- Missing icsEvent or source properties");
|
||||
console.log("- CSS styles not loaded or overridden");
|
||||
console.log("- getBadgeEventsForDate function not passed to view");
|
||||
|
||||
// This test always passes, it's just for documentation
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
693
src/__tests__/calendar-badge-rendering.test.ts
Normal file
693
src/__tests__/calendar-badge-rendering.test.ts
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
/**
|
||||
* Calendar Badge Rendering Tests
|
||||
* Tests the badge rendering logic for ICS events
|
||||
*/
|
||||
|
||||
import { moment } from "obsidian";
|
||||
import { IcsTask, IcsEvent, IcsSource } from "../types/ics";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
describe("Calendar Badge Rendering Logic", () => {
|
||||
// Mock ICS sources
|
||||
const badgeSource: IcsSource = {
|
||||
id: "test-badge-source",
|
||||
name: "Test Badge Calendar",
|
||||
url: "https://example.com/calendar.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const eventSource: IcsSource = {
|
||||
id: "test-event-source",
|
||||
name: "Test Event Calendar",
|
||||
url: "https://example.com/calendar2.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
color: "#4ecdc4",
|
||||
};
|
||||
|
||||
// Helper function to simulate getBadgeEventsForDate logic
|
||||
function getBadgeEventsForDate(
|
||||
tasks: Task[],
|
||||
date: Date
|
||||
): {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}[] {
|
||||
const targetDate = moment(date).startOf("day");
|
||||
const badgeEvents: Map<
|
||||
string,
|
||||
{
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
const eventDate = moment(icsTask.icsEvent.dtstart).startOf(
|
||||
"day"
|
||||
);
|
||||
|
||||
// Check if the event is on the target date
|
||||
if (eventDate.isSame(targetDate)) {
|
||||
const sourceId = icsTask.icsEvent.source.id;
|
||||
const existing = badgeEvents.get(sourceId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
badgeEvents.set(sourceId, {
|
||||
sourceId: sourceId,
|
||||
sourceName: icsTask.icsEvent.source.name,
|
||||
count: 1,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(badgeEvents.values());
|
||||
}
|
||||
|
||||
// Helper function to simulate processTasks logic for filtering badge events
|
||||
function filterBadgeEvents(tasks: Task[]): Task[] {
|
||||
return tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
// Skip ICS tasks with badge showType
|
||||
return !(isIcsTask && showAsBadge);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Badge Event Detection", () => {
|
||||
test("should identify ICS tasks with badge showType", () => {
|
||||
// Create mock ICS events
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource,
|
||||
};
|
||||
|
||||
// Create mock ICS tasks
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "ics-test-event-source-event-event-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Test Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
|
||||
// Test badge event filtering
|
||||
const badgeTasks = tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
return icsTask?.icsEvent?.source?.showType === "badge";
|
||||
});
|
||||
|
||||
const regularEvents = filterBadgeEvents(tasks);
|
||||
|
||||
expect(badgeTasks).toHaveLength(1);
|
||||
expect(badgeTasks[0].id).toBe(badgeTask.id);
|
||||
|
||||
expect(regularEvents).toHaveLength(1);
|
||||
expect(regularEvents[0].id).toBe(eventTask.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge Event Generation", () => {
|
||||
test("should generate badge events for specific date", () => {
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
const result = getBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sourceId).toBe(badgeSource.id);
|
||||
expect(result[0].sourceName).toBe(badgeSource.name);
|
||||
expect(result[0].count).toBe(1);
|
||||
expect(result[0].color).toBe(badgeSource.color);
|
||||
});
|
||||
|
||||
test("should handle multiple badge events from same source", () => {
|
||||
const badgeEvent1: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeEvent2: IcsEvent = {
|
||||
uid: "badge-event-2",
|
||||
summary: "Badge Event 2",
|
||||
description: "This should also appear as a badge",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask1: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent1.dtstart.getTime(),
|
||||
dueDate: badgeEvent1.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent1.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent1,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const badgeTask2: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-2",
|
||||
content: "Badge Event 2",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 2",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent2.dtstart.getTime(),
|
||||
dueDate: badgeEvent2.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent2.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent2,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask1, badgeTask2];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
const result = getBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sourceId).toBe(badgeSource.id);
|
||||
expect(result[0].count).toBe(2); // Should aggregate count from same source
|
||||
});
|
||||
|
||||
test("should handle multiple badge sources on same date", () => {
|
||||
const source1: IcsSource = {
|
||||
id: "source-1",
|
||||
name: "Calendar 1",
|
||||
url: "https://example.com/cal1.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const source2: IcsSource = {
|
||||
id: "source-2",
|
||||
name: "Calendar 2",
|
||||
url: "https://example.com/cal2.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#4ecdc4",
|
||||
};
|
||||
|
||||
const event1: IcsEvent = {
|
||||
uid: "event-1",
|
||||
summary: "Event from Calendar 1",
|
||||
description: "",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: source1,
|
||||
};
|
||||
|
||||
const event2: IcsEvent = {
|
||||
uid: "event-2",
|
||||
summary: "Event from Calendar 2",
|
||||
description: "",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: source2,
|
||||
};
|
||||
|
||||
const task1: IcsTask = {
|
||||
id: "ics-source-1-event-1",
|
||||
content: "Event from Calendar 1",
|
||||
filePath: "ics://Calendar 1",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Event from Calendar 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: event1.dtstart.getTime(),
|
||||
dueDate: event1.dtend?.getTime(),
|
||||
scheduledDate: event1.dtstart.getTime(),
|
||||
project: source1.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: event1,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: source1.name,
|
||||
id: source1.id,
|
||||
},
|
||||
};
|
||||
|
||||
const task2: IcsTask = {
|
||||
id: "ics-source-2-event-2",
|
||||
content: "Event from Calendar 2",
|
||||
filePath: "ics://Calendar 2",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Event from Calendar 2",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: event2.dtstart.getTime(),
|
||||
dueDate: event2.dtend?.getTime(),
|
||||
scheduledDate: event2.dtstart.getTime(),
|
||||
project: source2.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: event2,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: source2.name,
|
||||
id: source2.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [task1, task2];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
const result = getBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Find badges by source ID
|
||||
const badge1 = result.find((b) => b.sourceId === source1.id);
|
||||
const badge2 = result.find((b) => b.sourceId === source2.id);
|
||||
|
||||
expect(badge1).toBeDefined();
|
||||
expect(badge1!.count).toBe(1);
|
||||
expect(badge1!.color).toBe(source1.color);
|
||||
|
||||
expect(badge2).toBeDefined();
|
||||
expect(badge2!.count).toBe(1);
|
||||
expect(badge2!.color).toBe(source2.color);
|
||||
});
|
||||
|
||||
test("should return empty array when no badge events exist", () => {
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource, // This has showType: "event", not "badge"
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "ics-test-event-source-event-event-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Test Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [eventTask];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
const result = getBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should return empty array for dates with no events", () => {
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask];
|
||||
const targetDate = new Date("2024-01-16"); // Different date
|
||||
|
||||
const result = getBadgeEventsForDate(tasks, targetDate);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge Event Filtering", () => {
|
||||
test("should exclude badge events from regular calendar events", () => {
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "ics-test-event-source-event-event-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Test Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
|
||||
// Simulate processTasks logic for filtering out badge events
|
||||
const calendarEvents = filterBadgeEvents(tasks);
|
||||
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(eventTask.id);
|
||||
});
|
||||
|
||||
test("should include non-ICS tasks in regular calendar events", () => {
|
||||
const regularTask: Task = {
|
||||
id: "regular-task-1",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
dueDate: new Date("2024-01-15").getTime(),
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [regularTask, badgeTask];
|
||||
|
||||
const calendarEvents = filterBadgeEvents(tasks);
|
||||
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(regularTask.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
371
src/__tests__/calendar-performance.test.ts
Normal file
371
src/__tests__/calendar-performance.test.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* Calendar Performance Tests
|
||||
* Tests to verify performance optimizations work correctly
|
||||
*/
|
||||
|
||||
import { IcsTask, IcsEvent, IcsSource } from "../types/ics";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
describe("Calendar Performance Optimizations", () => {
|
||||
// Helper function to create badge tasks
|
||||
function createBadgeTasks(count: number, baseDate: Date): IcsTask[] {
|
||||
const tasks: IcsTask[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const eventDate = new Date(baseDate);
|
||||
eventDate.setDate(baseDate.getDate() + (i % 7)); // Spread across a week
|
||||
|
||||
const badgeSource: IcsSource = {
|
||||
id: `badge-source-${i}`,
|
||||
name: `Badge Calendar ${i}`,
|
||||
url: `https://example.com/cal${i}.ics`,
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`,
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: `badge-event-${i}`,
|
||||
summary: `Badge Event ${i}`,
|
||||
description: "Test badge event",
|
||||
dtstart: eventDate,
|
||||
dtend: new Date(eventDate.getTime() + 60 * 60 * 1000),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: `ics-badge-${i}`,
|
||||
content: `Badge Event ${i}`,
|
||||
filePath: `ics://${badgeSource.name}`,
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: `- [ ] Badge Event ${i}`,
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
badge: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
tasks.push(badgeTask);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// Optimized getBadgeEventsForDate function (extracted from CalendarComponent)
|
||||
function optimizedGetBadgeEventsForDate(tasks: Task[], date: Date): any[] {
|
||||
// Use native Date operations for better performance
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
|
||||
const badgeEventsForDate: any[] = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
// Use native Date operations instead of moment for better performance
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
const eventYear = eventDate.getFullYear();
|
||||
const eventMonth = eventDate.getMonth();
|
||||
const eventDay = eventDate.getDate();
|
||||
|
||||
// Check if the event is on the target date using native comparison
|
||||
if (
|
||||
eventYear === year &&
|
||||
eventMonth === month &&
|
||||
eventDay === day
|
||||
) {
|
||||
// Convert the task to a CalendarEvent format for consistency
|
||||
const calendarEvent = {
|
||||
...task,
|
||||
title: task.content,
|
||||
start: icsTask.icsEvent.dtstart,
|
||||
end: icsTask.icsEvent.dtend,
|
||||
allDay: icsTask.icsEvent.allDay,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
};
|
||||
badgeEventsForDate.push(calendarEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return badgeEventsForDate;
|
||||
}
|
||||
|
||||
// Legacy getBadgeEventsForDate function (using moment.js)
|
||||
function legacyGetBadgeEventsForDate(tasks: Task[], date: Date): any[] {
|
||||
// Simulate moment.js usage (without actually importing it to avoid test issues)
|
||||
const targetDateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD format
|
||||
|
||||
const badgeEventsForDate: any[] = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
// Simulate moment.js operations with more expensive date parsing
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
const eventDateStr = eventDate.toISOString().split("T")[0];
|
||||
|
||||
// Simulate moment.js comparison (more expensive)
|
||||
if (eventDateStr === targetDateStr) {
|
||||
const calendarEvent = {
|
||||
...task,
|
||||
title: task.content,
|
||||
start: icsTask.icsEvent.dtstart,
|
||||
end: icsTask.icsEvent.dtend,
|
||||
allDay: icsTask.icsEvent.allDay,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
};
|
||||
badgeEventsForDate.push(calendarEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return badgeEventsForDate;
|
||||
}
|
||||
|
||||
// Utility function to parse date string (YYYY-MM-DD) to Date object
|
||||
function parseDateString(dateStr: string): Date {
|
||||
const dateParts = dateStr.split("-");
|
||||
const year = parseInt(dateParts[0], 10);
|
||||
const month = parseInt(dateParts[1], 10) - 1; // Month is 0-indexed in Date
|
||||
const day = parseInt(dateParts[2], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
describe("Date Parsing Optimization", () => {
|
||||
test("should parse date strings efficiently", () => {
|
||||
const dateStr = "2024-01-15";
|
||||
|
||||
// Test our optimized date parsing
|
||||
const startTime = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
parseDateString(dateStr);
|
||||
}
|
||||
const endTime = performance.now();
|
||||
const optimizedTime = endTime - startTime;
|
||||
|
||||
// Test native Date parsing
|
||||
const startTime2 = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
new Date(dateStr);
|
||||
}
|
||||
const endTime2 = performance.now();
|
||||
const nativeTime = endTime2 - startTime2;
|
||||
|
||||
console.log(`Optimized parsing: ${optimizedTime.toFixed(2)}ms`);
|
||||
console.log(`Native parsing: ${nativeTime.toFixed(2)}ms`);
|
||||
|
||||
// Both should produce the same result
|
||||
const optimizedResult = parseDateString(dateStr);
|
||||
const nativeResult = new Date(dateStr);
|
||||
|
||||
expect(optimizedResult.getFullYear()).toBe(
|
||||
nativeResult.getFullYear()
|
||||
);
|
||||
expect(optimizedResult.getMonth()).toBe(nativeResult.getMonth());
|
||||
expect(optimizedResult.getDate()).toBe(nativeResult.getDate());
|
||||
});
|
||||
|
||||
test("should handle various date string formats", () => {
|
||||
const testCases = [
|
||||
"2024-01-15",
|
||||
"2024-12-31",
|
||||
"2023-02-28",
|
||||
"2024-02-29", // Leap year
|
||||
];
|
||||
|
||||
testCases.forEach((dateStr) => {
|
||||
const optimizedResult = parseDateString(dateStr);
|
||||
const nativeResult = new Date(dateStr);
|
||||
|
||||
expect(optimizedResult.getFullYear()).toBe(
|
||||
nativeResult.getFullYear()
|
||||
);
|
||||
expect(optimizedResult.getMonth()).toBe(
|
||||
nativeResult.getMonth()
|
||||
);
|
||||
expect(optimizedResult.getDate()).toBe(nativeResult.getDate());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge Events Performance", () => {
|
||||
test("should handle large number of tasks efficiently", () => {
|
||||
const baseDate = new Date("2024-01-15");
|
||||
const largeBadgeTaskSet = createBadgeTasks(1000, baseDate);
|
||||
|
||||
// Test optimized version
|
||||
const startTime = performance.now();
|
||||
|
||||
const testDates = [
|
||||
new Date("2024-01-15"),
|
||||
new Date("2024-01-16"),
|
||||
new Date("2024-01-17"),
|
||||
new Date("2024-01-18"),
|
||||
new Date("2024-01-19"),
|
||||
];
|
||||
|
||||
testDates.forEach((date) => {
|
||||
optimizedGetBadgeEventsForDate(largeBadgeTaskSet, date);
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const optimizedTime = endTime - startTime;
|
||||
|
||||
// Test legacy version
|
||||
const startTime2 = performance.now();
|
||||
|
||||
testDates.forEach((date) => {
|
||||
legacyGetBadgeEventsForDate(largeBadgeTaskSet, date);
|
||||
});
|
||||
|
||||
const endTime2 = performance.now();
|
||||
const legacyTime = endTime2 - startTime2;
|
||||
|
||||
console.log(
|
||||
`Optimized version: ${optimizedTime.toFixed(
|
||||
2
|
||||
)}ms for 1000 tasks`
|
||||
);
|
||||
console.log(
|
||||
`Legacy version: ${legacyTime.toFixed(2)}ms for 1000 tasks`
|
||||
);
|
||||
|
||||
// Optimized version should be faster or at least not significantly slower
|
||||
expect(optimizedTime).toBeLessThan(legacyTime * 1.5); // Allow 50% tolerance
|
||||
|
||||
// Both should produce the same results
|
||||
const optimizedResult = optimizedGetBadgeEventsForDate(
|
||||
largeBadgeTaskSet,
|
||||
testDates[0]
|
||||
);
|
||||
const legacyResult = legacyGetBadgeEventsForDate(
|
||||
largeBadgeTaskSet,
|
||||
testDates[0]
|
||||
);
|
||||
|
||||
expect(optimizedResult.length).toBe(legacyResult.length);
|
||||
});
|
||||
|
||||
test("should correctly identify badge events", () => {
|
||||
const baseDate = new Date("2024-01-15");
|
||||
const badgeTasks = createBadgeTasks(5, baseDate);
|
||||
|
||||
const result = optimizedGetBadgeEventsForDate(badgeTasks, baseDate);
|
||||
|
||||
// Should return badge events for the specified date
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the events are for the correct date
|
||||
result.forEach((event) => {
|
||||
const eventDate = new Date(event.start);
|
||||
expect(eventDate.getFullYear()).toBe(2024);
|
||||
expect(eventDate.getMonth()).toBe(0); // January (0-indexed)
|
||||
expect(eventDate.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle edge cases correctly", () => {
|
||||
// Test with empty task list
|
||||
const emptyResult = optimizedGetBadgeEventsForDate(
|
||||
[],
|
||||
new Date("2024-01-15")
|
||||
);
|
||||
expect(emptyResult).toHaveLength(0);
|
||||
|
||||
// Test with non-badge tasks
|
||||
const regularTask: Task = {
|
||||
id: "regular-task",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
dueDate: new Date("2024-01-15").getTime(),
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const regularResult = optimizedGetBadgeEventsForDate(
|
||||
[regularTask],
|
||||
new Date("2024-01-15")
|
||||
);
|
||||
expect(regularResult).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching Simulation", () => {
|
||||
test("should demonstrate cache benefits", () => {
|
||||
const baseDate = new Date("2024-01-15");
|
||||
const badgeTasks = createBadgeTasks(100, baseDate);
|
||||
|
||||
// Simulate cache implementation
|
||||
const cache = new Map<string, any[]>();
|
||||
|
||||
function getCachedBadgeEvents(tasks: Task[], date: Date): any[] {
|
||||
const dateKey = `${date.getFullYear()}-${String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
|
||||
if (cache.has(dateKey)) {
|
||||
return cache.get(dateKey) || [];
|
||||
}
|
||||
|
||||
const result = optimizedGetBadgeEventsForDate(tasks, date);
|
||||
cache.set(dateKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// First call - should compute and cache
|
||||
const startTime1 = performance.now();
|
||||
const result1 = getCachedBadgeEvents(badgeTasks, baseDate);
|
||||
const endTime1 = performance.now();
|
||||
const firstCallTime = endTime1 - startTime1;
|
||||
|
||||
// Second call - should use cache
|
||||
const startTime2 = performance.now();
|
||||
const result2 = getCachedBadgeEvents(badgeTasks, baseDate);
|
||||
const endTime2 = performance.now();
|
||||
const secondCallTime = endTime2 - startTime2;
|
||||
|
||||
console.log(`First call (compute): ${firstCallTime.toFixed(2)}ms`);
|
||||
console.log(`Second call (cached): ${secondCallTime.toFixed(2)}ms`);
|
||||
|
||||
// Results should be identical
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
// Second call should be faster (cached)
|
||||
expect(secondCallTime).toBeLessThan(firstCallTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
609
src/__tests__/calendar-view-integration.test.ts
Normal file
609
src/__tests__/calendar-view-integration.test.ts
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
/**
|
||||
* Calendar View Integration Tests
|
||||
* Tests the integration between badge logic and calendar view rendering
|
||||
*/
|
||||
|
||||
import { moment } from "obsidian";
|
||||
import { IcsTask, IcsEvent, IcsSource } from "../types/ics";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
describe("Calendar View Badge Integration", () => {
|
||||
// Test the integration logic that would be used in the actual calendar component
|
||||
|
||||
// Mock the CalendarComponent's getBadgeEventsForDate method
|
||||
function simulateGetBadgeEventsForDate(
|
||||
tasks: Task[],
|
||||
date: Date
|
||||
): {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}[] {
|
||||
const targetDate = moment(date).startOf("day");
|
||||
const badgeEvents: Map<
|
||||
string,
|
||||
{
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
const eventDate = moment(icsTask.icsEvent.dtstart).startOf(
|
||||
"day"
|
||||
);
|
||||
|
||||
// Check if the event is on the target date
|
||||
if (eventDate.isSame(targetDate)) {
|
||||
const sourceId = icsTask.icsEvent.source.id;
|
||||
const existing = badgeEvents.get(sourceId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
badgeEvents.set(sourceId, {
|
||||
sourceId: sourceId,
|
||||
sourceName: icsTask.icsEvent.source.name,
|
||||
count: 1,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(badgeEvents.values());
|
||||
}
|
||||
|
||||
// Mock the CalendarComponent's processTasks method
|
||||
function simulateProcessTasks(tasks: Task[]): Task[] {
|
||||
return tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
// Skip ICS tasks with badge showType - they will be handled separately
|
||||
return !(isIcsTask && showAsBadge);
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate the month view rendering logic for badges
|
||||
function simulateMonthViewBadgeRendering(
|
||||
tasks: Task[],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): { [dateStr: string]: any[] } {
|
||||
const badgesByDate: { [dateStr: string]: any[] } = {};
|
||||
|
||||
// Simulate iterating through each day in the month view
|
||||
let currentDate = new Date(startDate);
|
||||
while (currentDate <= endDate) {
|
||||
const dateStr = currentDate.toISOString().split("T")[0];
|
||||
const badgeEvents = simulateGetBadgeEventsForDate(
|
||||
tasks,
|
||||
currentDate
|
||||
);
|
||||
|
||||
if (badgeEvents.length > 0) {
|
||||
badgesByDate[dateStr] = badgeEvents.map((badgeEvent) => ({
|
||||
cls: "calendar-badge",
|
||||
title: `${badgeEvent.sourceName}: ${badgeEvent.count} events`,
|
||||
backgroundColor: badgeEvent.color,
|
||||
textContent: badgeEvent.count.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return badgesByDate;
|
||||
}
|
||||
|
||||
describe("Badge Rendering Integration", () => {
|
||||
test("should render badges in month view for badge events", () => {
|
||||
// Create test data
|
||||
const badgeSource: IcsSource = {
|
||||
id: "test-badge-source",
|
||||
name: "Test Badge Calendar",
|
||||
url: "https://example.com/calendar.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask];
|
||||
|
||||
// Simulate month view rendering
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// Verify badge is rendered on the correct date
|
||||
expect(badgesByDate["2024-01-15"]).toBeDefined();
|
||||
expect(badgesByDate["2024-01-15"]).toHaveLength(1);
|
||||
|
||||
const badge = badgesByDate["2024-01-15"][0];
|
||||
expect(badge.cls).toBe("calendar-badge");
|
||||
expect(badge.textContent).toBe("1");
|
||||
expect(badge.backgroundColor).toBe("#ff6b6b");
|
||||
expect(badge.title).toBe("Test Badge Calendar: 1 events");
|
||||
|
||||
// Verify no badges on other dates
|
||||
expect(badgesByDate["2024-01-14"]).toBeUndefined();
|
||||
expect(badgesByDate["2024-01-16"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should not render badges for regular events", () => {
|
||||
// Create test data with regular event (not badge)
|
||||
const eventSource: IcsSource = {
|
||||
id: "test-event-source",
|
||||
name: "Test Event Calendar",
|
||||
url: "https://example.com/calendar2.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event", // This should NOT appear as badge
|
||||
color: "#4ecdc4",
|
||||
};
|
||||
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource,
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "ics-test-event-source-event-event-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Test Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [eventTask];
|
||||
|
||||
// Simulate month view rendering
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// Verify no badges are rendered
|
||||
expect(Object.keys(badgesByDate)).toHaveLength(0);
|
||||
|
||||
// Verify the task would be included in regular calendar events
|
||||
const calendarEvents = simulateProcessTasks(tasks);
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(eventTask.id);
|
||||
});
|
||||
|
||||
test("should handle mixed badge and regular events correctly", () => {
|
||||
// Create mixed test data
|
||||
const badgeSource: IcsSource = {
|
||||
id: "badge-source",
|
||||
name: "Badge Calendar",
|
||||
url: "https://example.com/badge.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const eventSource: IcsSource = {
|
||||
id: "event-source",
|
||||
name: "Event Calendar",
|
||||
url: "https://example.com/event.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
color: "#4ecdc4",
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "badge-task-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "event-task-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: false,
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
|
||||
// Test badge rendering
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// Verify badge is rendered for badge event
|
||||
expect(badgesByDate["2024-01-15"]).toBeDefined();
|
||||
expect(badgesByDate["2024-01-15"]).toHaveLength(1);
|
||||
expect(badgesByDate["2024-01-15"][0].textContent).toBe("1");
|
||||
|
||||
// Test regular event processing
|
||||
const calendarEvents = simulateProcessTasks(tasks);
|
||||
|
||||
// Verify only the regular event is included in calendar events
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(eventTask.id);
|
||||
});
|
||||
|
||||
test("should aggregate multiple badge events from same source", () => {
|
||||
const badgeSource: IcsSource = {
|
||||
id: "badge-source",
|
||||
name: "Badge Calendar",
|
||||
url: "https://example.com/badge.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
// Create multiple events on the same day
|
||||
const badgeEvent1: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeEvent2: IcsEvent = {
|
||||
uid: "badge-event-2",
|
||||
summary: "Badge Event 2",
|
||||
description: "",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask1: IcsTask = {
|
||||
id: "badge-task-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent1.dtstart.getTime(),
|
||||
dueDate: badgeEvent1.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent1.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent1,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const badgeTask2: IcsTask = {
|
||||
id: "badge-task-2",
|
||||
content: "Badge Event 2",
|
||||
filePath: "ics://Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 2",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent2.dtstart.getTime(),
|
||||
dueDate: badgeEvent2.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent2.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent2,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask1, badgeTask2];
|
||||
|
||||
// Test badge rendering
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// Verify badge shows aggregated count
|
||||
expect(badgesByDate["2024-01-15"]).toBeDefined();
|
||||
expect(badgesByDate["2024-01-15"]).toHaveLength(1);
|
||||
expect(badgesByDate["2024-01-15"][0].textContent).toBe("2");
|
||||
expect(badgesByDate["2024-01-15"][0].title).toBe(
|
||||
"Badge Calendar: 2 events"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Badge Rendering Edge Cases", () => {
|
||||
test("should handle empty task list", () => {
|
||||
const tasks: Task[] = [];
|
||||
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
expect(Object.keys(badgesByDate)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should handle tasks without ICS events", () => {
|
||||
const regularTask: Task = {
|
||||
id: "regular-task-1",
|
||||
content: "Regular Task",
|
||||
filePath: "test.md",
|
||||
line: 1,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Regular Task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
dueDate: new Date("2024-01-15").getTime(),
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [regularTask];
|
||||
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// No badges should be rendered for regular tasks
|
||||
expect(Object.keys(badgesByDate)).toHaveLength(0);
|
||||
|
||||
// But the task should be included in regular calendar events
|
||||
const calendarEvents = simulateProcessTasks(tasks);
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(regularTask.id);
|
||||
});
|
||||
|
||||
test("should handle badge events outside date range", () => {
|
||||
const badgeSource: IcsSource = {
|
||||
id: "badge-source",
|
||||
name: "Badge Calendar",
|
||||
url: "https://example.com/badge.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge",
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "",
|
||||
dtstart: new Date("2024-02-15T10:00:00Z"), // Outside January range
|
||||
dtend: new Date("2024-02-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const badgeTask: IcsTask = {
|
||||
id: "badge-task-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
badge: true,
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask];
|
||||
|
||||
// Test January range
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
const badgesByDate = simulateMonthViewBadgeRendering(
|
||||
tasks,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// No badges should be rendered in January
|
||||
expect(Object.keys(badgesByDate)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -906,4 +906,473 @@ describe("handleCycleCompleteStatusTransaction (Integration)", () => {
|
|||
expect(result.annotations).not.toBe("taskStatusChange");
|
||||
expect(result).toBe(tr);
|
||||
});
|
||||
|
||||
it("should cycle task status when user selects and replaces the 'x' mark with any character", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test replacing 'x' with 'a' (any character)
|
||||
const tr1 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [a] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "a" },
|
||||
],
|
||||
});
|
||||
const result1 = handleCycleCompleteStatusTransaction(
|
||||
tr1,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result1).not.toBe(tr1);
|
||||
const changes1 = Array.isArray(result1.changes)
|
||||
? result1.changes
|
||||
: result1.changes
|
||||
? [result1.changes]
|
||||
: [];
|
||||
expect(changes1).toHaveLength(1);
|
||||
expect(changes1[0].from).toBe(3);
|
||||
expect(changes1[0].to).toBe(4);
|
||||
expect(changes1[0].insert).toBe(" "); // Should cycle from 'x' to ' ' (next in cycle)
|
||||
expect(result1.annotations).toBe("taskStatusChange");
|
||||
|
||||
// Test replacing 'x' with '1' (number)
|
||||
const tr2 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [1] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "1" },
|
||||
],
|
||||
});
|
||||
const result2 = handleCycleCompleteStatusTransaction(
|
||||
tr2,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result2).not.toBe(tr2);
|
||||
const changes2 = Array.isArray(result2.changes)
|
||||
? result2.changes
|
||||
: result2.changes
|
||||
? [result2.changes]
|
||||
: [];
|
||||
expect(changes2).toHaveLength(1);
|
||||
expect(changes2[0].from).toBe(3);
|
||||
expect(changes2[0].to).toBe(4);
|
||||
expect(changes2[0].insert).toBe(" "); // Should cycle from 'x' to ' ' (next in cycle)
|
||||
expect(result2.annotations).toBe("taskStatusChange");
|
||||
|
||||
// Test replacing 'x' with '!' (special character)
|
||||
const tr3 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [!] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "!" },
|
||||
],
|
||||
});
|
||||
const result3 = handleCycleCompleteStatusTransaction(
|
||||
tr3,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result3).not.toBe(tr3);
|
||||
const changes3 = Array.isArray(result3.changes)
|
||||
? result3.changes
|
||||
: result3.changes
|
||||
? [result3.changes]
|
||||
: [];
|
||||
expect(changes3).toHaveLength(1);
|
||||
expect(changes3[0].from).toBe(3);
|
||||
expect(changes3[0].to).toBe(4);
|
||||
expect(changes3[0].insert).toBe(" "); // Should cycle from 'x' to ' ' (next in cycle)
|
||||
expect(result3.annotations).toBe("taskStatusChange");
|
||||
});
|
||||
|
||||
it("should cycle task status when user selects and replaces any mark with any character", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test replacing ' ' (space) with 'z'
|
||||
const tr1 = createMockTransaction({
|
||||
startStateDocContent: "- [ ] Task",
|
||||
newDocContent: "- [z] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "z" },
|
||||
],
|
||||
});
|
||||
const result1 = handleCycleCompleteStatusTransaction(
|
||||
tr1,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result1).not.toBe(tr1);
|
||||
const changes1 = Array.isArray(result1.changes)
|
||||
? result1.changes
|
||||
: result1.changes
|
||||
? [result1.changes]
|
||||
: [];
|
||||
expect(changes1).toHaveLength(1);
|
||||
expect(changes1[0].from).toBe(3);
|
||||
expect(changes1[0].to).toBe(4);
|
||||
expect(changes1[0].insert).toBe("/"); // Should cycle from ' ' to '/' (next in cycle)
|
||||
expect(result1.annotations).toBe("taskStatusChange");
|
||||
|
||||
// Test replacing '/' with 'q'
|
||||
const tr2 = createMockTransaction({
|
||||
startStateDocContent: "- [/] Task",
|
||||
newDocContent: "- [q] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "q" },
|
||||
],
|
||||
});
|
||||
const result2 = handleCycleCompleteStatusTransaction(
|
||||
tr2,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result2).not.toBe(tr2);
|
||||
const changes2 = Array.isArray(result2.changes)
|
||||
? result2.changes
|
||||
: result2.changes
|
||||
? [result2.changes]
|
||||
: [];
|
||||
expect(changes2).toHaveLength(1);
|
||||
expect(changes2[0].from).toBe(3);
|
||||
expect(changes2[0].to).toBe(4);
|
||||
expect(changes2[0].insert).toBe("x"); // Should cycle from '/' to 'x' (next in cycle)
|
||||
expect(result2.annotations).toBe("taskStatusChange");
|
||||
});
|
||||
|
||||
it("should correctly detect the original mark in replacement operations", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test the specific case where user selects 'x' and replaces it with 'a'
|
||||
// This is a replacement operation: fromA=3, toA=4 (deleting 'x'), fromB=3, toB=4 (inserting 'a')
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [a] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "a" },
|
||||
],
|
||||
});
|
||||
|
||||
// First, let's test what findTaskStatusChanges returns
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
expect(taskChanges).toHaveLength(1);
|
||||
|
||||
// The currentMark should be 'x' (the original mark that was replaced)
|
||||
// NOT 'a' (the new mark that was typed)
|
||||
expect(taskChanges[0].currentMark).toBe("x");
|
||||
expect(taskChanges[0].position).toBe(3);
|
||||
|
||||
// Now test the full cycle behavior
|
||||
const result = handleCycleCompleteStatusTransaction(
|
||||
tr,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result).not.toBe(tr);
|
||||
const changes = Array.isArray(result.changes)
|
||||
? result.changes
|
||||
: result.changes
|
||||
? [result.changes]
|
||||
: [];
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].from).toBe(3);
|
||||
expect(changes[0].to).toBe(4);
|
||||
expect(changes[0].insert).toBe(" "); // Should cycle from 'x' to ' ' (next in cycle)
|
||||
expect(result.annotations).toBe("taskStatusChange");
|
||||
});
|
||||
|
||||
it("should handle replacement operations where fromA != toA", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test replacement operation: user selects 'x' and types 'z'
|
||||
// This should be detected as a replacement, not just an insertion
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [z] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "z" },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify that this is detected as a task status change
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
expect(taskChanges).toHaveLength(1);
|
||||
expect(taskChanges[0].currentMark).toBe("x"); // Original mark before replacement
|
||||
expect(taskChanges[0].wasCompleteTask).toBe(true);
|
||||
|
||||
// Verify the cycling behavior
|
||||
const result = handleCycleCompleteStatusTransaction(
|
||||
tr,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result).not.toBe(tr);
|
||||
const changes = Array.isArray(result.changes)
|
||||
? result.changes
|
||||
: result.changes
|
||||
? [result.changes]
|
||||
: [];
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].insert).toBe(" "); // Should cycle from 'x' to ' '
|
||||
});
|
||||
|
||||
it("should debug replacement with space character specifically", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test the specific case: user selects 'x' and types space ' '
|
||||
// This might be the problematic case you mentioned
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [ ] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: " " },
|
||||
],
|
||||
});
|
||||
|
||||
// Debug: Check what findTaskStatusChanges detects
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
console.log("Debug - taskChanges for space replacement:", taskChanges);
|
||||
|
||||
if (taskChanges.length > 0) {
|
||||
console.log("Debug - currentMark:", taskChanges[0].currentMark);
|
||||
console.log("Debug - position:", taskChanges[0].position);
|
||||
console.log(
|
||||
"Debug - wasCompleteTask:",
|
||||
taskChanges[0].wasCompleteTask
|
||||
);
|
||||
}
|
||||
|
||||
// Test the full cycle behavior
|
||||
const result = handleCycleCompleteStatusTransaction(
|
||||
tr,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
console.log("Debug - result === tr:", result === tr);
|
||||
console.log("Debug - result.changes:", result.changes);
|
||||
|
||||
// If this is the problematic case, the result might be different
|
||||
if (result !== tr) {
|
||||
const changes = Array.isArray(result.changes)
|
||||
? result.changes
|
||||
: result.changes
|
||||
? [result.changes]
|
||||
: [];
|
||||
console.log("Debug - changes length:", changes.length);
|
||||
if (changes.length > 0) {
|
||||
console.log("Debug - first change:", changes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// For now, let's just verify it's detected as a change
|
||||
expect(taskChanges).toHaveLength(1);
|
||||
expect(taskChanges[0].currentMark).toBe("x"); // Should detect original 'x'
|
||||
});
|
||||
|
||||
it("should test different replacement scenarios to identify the trigger", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test 1: Replace 'x' with 'a' (non-space character)
|
||||
const tr1 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [a] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "a" },
|
||||
],
|
||||
});
|
||||
|
||||
const taskChanges1 = findTaskStatusChanges(tr1, false);
|
||||
const result1 = handleCycleCompleteStatusTransaction(
|
||||
tr1,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
console.log("Test 1 (x->a): taskChanges length:", taskChanges1.length);
|
||||
console.log("Test 1 (x->a): result changed:", result1 !== tr1);
|
||||
|
||||
// Test 2: Replace 'x' with ' ' (space character)
|
||||
const tr2 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [ ] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: " " },
|
||||
],
|
||||
});
|
||||
|
||||
const taskChanges2 = findTaskStatusChanges(tr2, false);
|
||||
const result2 = handleCycleCompleteStatusTransaction(
|
||||
tr2,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
console.log("Test 2 (x-> ): taskChanges length:", taskChanges2.length);
|
||||
console.log("Test 2 (x-> ): result changed:", result2 !== tr2);
|
||||
|
||||
// Test 3: Replace '/' with ' ' (space character)
|
||||
const tr3 = createMockTransaction({
|
||||
startStateDocContent: "- [/] Task",
|
||||
newDocContent: "- [ ] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: " " },
|
||||
],
|
||||
});
|
||||
|
||||
const taskChanges3 = findTaskStatusChanges(tr3, false);
|
||||
const result3 = handleCycleCompleteStatusTransaction(
|
||||
tr3,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
console.log("Test 3 (/-> ): taskChanges length:", taskChanges3.length);
|
||||
console.log("Test 3 (/-> ): result changed:", result3 !== tr3);
|
||||
|
||||
// Test 4: Replace ' ' with 'x' (completing a task)
|
||||
const tr4 = createMockTransaction({
|
||||
startStateDocContent: "- [ ] Task",
|
||||
newDocContent: "- [x] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "x" },
|
||||
],
|
||||
});
|
||||
|
||||
const taskChanges4 = findTaskStatusChanges(tr4, false);
|
||||
const result4 = handleCycleCompleteStatusTransaction(
|
||||
tr4,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
console.log("Test 4 ( ->x): taskChanges length:", taskChanges4.length);
|
||||
console.log("Test 4 ( ->x): result changed:", result4 !== tr4);
|
||||
|
||||
// All should be detected as task changes
|
||||
expect(taskChanges1).toHaveLength(1);
|
||||
expect(taskChanges2).toHaveLength(1);
|
||||
expect(taskChanges3).toHaveLength(1);
|
||||
expect(taskChanges4).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should identify the exact problem: when user input matches next cycle state", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
// Cycle: ' ' -> '/' -> 'x' -> ' '
|
||||
|
||||
// Problem case: User replaces 'x' with ' ' (which is the correct next state)
|
||||
// But the system detects currentMark='x', calculates nextMark=' ',
|
||||
// and since user already typed ' ', it should NOT cycle again
|
||||
const tr = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [ ] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: " " },
|
||||
],
|
||||
});
|
||||
|
||||
const taskChanges = findTaskStatusChanges(tr, false);
|
||||
console.log("Problem case - taskChanges:", taskChanges);
|
||||
|
||||
// The issue: currentMark should be 'x' (original), but
|
||||
// user typed ' ' (space) which happens to be the next mark in cycle
|
||||
// System calculates nextMark=' ' and user input=' ', so they match
|
||||
// Should NOT trigger another cycle
|
||||
|
||||
const result = handleCycleCompleteStatusTransaction(
|
||||
tr,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
// Debug output
|
||||
if (taskChanges.length > 0) {
|
||||
const taskChange = taskChanges[0];
|
||||
console.log("Current mark (original):", taskChange.currentMark);
|
||||
|
||||
// Get user's typed character
|
||||
let userTyped = "";
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
if (fromB === taskChange.position) {
|
||||
userTyped = inserted.toString();
|
||||
}
|
||||
});
|
||||
console.log("User typed:", userTyped);
|
||||
|
||||
// Calculate what the next mark should be
|
||||
const marks = mockPlugin.settings.taskStatusMarks;
|
||||
const cycle = mockPlugin.settings.taskStatusCycle;
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < cycle.length; i++) {
|
||||
if (marks[cycle[i]] === taskChange.currentMark) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const nextIndex = (currentIndex + 1) % cycle.length;
|
||||
const nextMark = marks[cycle[nextIndex]];
|
||||
console.log("Next mark (calculated):", nextMark);
|
||||
console.log(
|
||||
"User input matches next mark:",
|
||||
userTyped === nextMark
|
||||
);
|
||||
console.log("System wants to change to:", nextMark);
|
||||
}
|
||||
|
||||
// The result should be the original transaction (no cycling)
|
||||
// Because user already typed the correct next character
|
||||
expect(result).toBe(tr);
|
||||
});
|
||||
|
||||
it("should NOT cycle when user manually replaces task marker with any character", () => {
|
||||
const mockPlugin = createMockPlugin(); // Defaults: ' ', '/', 'x'
|
||||
|
||||
// Test 1: User selects 'x' and types 'a' (replacement operation)
|
||||
const tr1 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [a] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "a" },
|
||||
],
|
||||
});
|
||||
|
||||
const result1 = handleCycleCompleteStatusTransaction(
|
||||
tr1,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result1).toBe(tr1); // Should not cycle, keep user input 'a'
|
||||
|
||||
// Test 2: User selects 'x' and types ' ' (replacement operation)
|
||||
const tr2 = createMockTransaction({
|
||||
startStateDocContent: "- [x] Task",
|
||||
newDocContent: "- [ ] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: " " },
|
||||
],
|
||||
});
|
||||
|
||||
const result2 = handleCycleCompleteStatusTransaction(
|
||||
tr2,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result2).toBe(tr2); // Should not cycle, keep user input ' '
|
||||
|
||||
// Test 3: User selects ' ' and types 'z' (replacement operation)
|
||||
const tr3 = createMockTransaction({
|
||||
startStateDocContent: "- [ ] Task",
|
||||
newDocContent: "- [z] Task",
|
||||
changes: [
|
||||
{ fromA: 3, toA: 4, fromB: 3, toB: 4, insertedText: "z" },
|
||||
],
|
||||
});
|
||||
|
||||
const result3 = handleCycleCompleteStatusTransaction(
|
||||
tr3,
|
||||
mockApp,
|
||||
mockPlugin
|
||||
);
|
||||
expect(result3).toBe(tr3); // Should not cycle, keep user input 'z'
|
||||
});
|
||||
});
|
||||
|
|
|
|||
97
src/__tests__/dateTemplates.test.ts
Normal file
97
src/__tests__/dateTemplates.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { processDateTemplates } from "../utils/fileUtils";
|
||||
|
||||
// Mock moment function to return predictable results
|
||||
jest.mock("obsidian", () => ({
|
||||
moment: jest.fn(() => ({
|
||||
format: jest.fn((format: string) => {
|
||||
// Mock current date as 2024-01-15 14:30:00
|
||||
switch (format) {
|
||||
case "YYYY-MM-DD":
|
||||
return "2024-01-15";
|
||||
case "YYYY-MM-DD HH:mm":
|
||||
return "2024-01-15 14:30";
|
||||
case "YYYY":
|
||||
return "2024";
|
||||
case "MM":
|
||||
return "01";
|
||||
case "DD":
|
||||
return "15";
|
||||
case "HH:mm":
|
||||
return "14:30";
|
||||
default:
|
||||
return format; // Return format as-is for unknown formats
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Date Templates", () => {
|
||||
test("should replace {{DATE:YYYY-MM-DD}} with current date", () => {
|
||||
const input = "folder/{{DATE:YYYY-MM-DD}}.md";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("folder/2024-01-15.md");
|
||||
});
|
||||
|
||||
test("should replace {{date:YYYY-MM-DD HH:mm}} with current date and time", () => {
|
||||
const input = "notes/{{date:YYYY-MM-DD HH:mm}}.md";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("notes/2024-01-15 14-30.md");
|
||||
});
|
||||
|
||||
test("should handle multiple date templates in one path", () => {
|
||||
const input = "{{DATE:YYYY}}/{{DATE:MM}}/{{DATE:DD}}.md";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("2024/01/15.md");
|
||||
});
|
||||
|
||||
test("should handle case insensitive DATE keyword", () => {
|
||||
const input1 = "{{DATE:YYYY-MM-DD}}.md";
|
||||
const input2 = "{{date:YYYY-MM-DD}}.md";
|
||||
|
||||
const result1 = processDateTemplates(input1);
|
||||
const result2 = processDateTemplates(input2);
|
||||
|
||||
expect(result1).toBe("2024-01-15.md");
|
||||
expect(result2).toBe("2024-01-15.md");
|
||||
});
|
||||
|
||||
test("should leave non-template text unchanged", () => {
|
||||
const input = "regular/path/file.md";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("regular/path/file.md");
|
||||
});
|
||||
|
||||
test("should handle mixed template and regular text", () => {
|
||||
const input = "daily-notes/{{DATE:YYYY-MM-DD}}-journal.md";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("daily-notes/2024-01-15-journal.md");
|
||||
});
|
||||
|
||||
test("should handle invalid format gracefully", () => {
|
||||
const input = "{{DATE:INVALID_FORMAT}}.md";
|
||||
const result = processDateTemplates(input);
|
||||
// Should return the original template if format is invalid
|
||||
expect(result).toBe("INVALID_FORMAT.md");
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
const input = "";
|
||||
const result = processDateTemplates(input);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
test("should handle malformed templates", () => {
|
||||
const input1 = "{{DATE:}}.md"; // Empty format
|
||||
const input2 = "{{DATE.md"; // Missing closing braces
|
||||
const input3 = "DATE:YYYY-MM-DD}}.md"; // Missing opening braces
|
||||
|
||||
const result1 = processDateTemplates(input1);
|
||||
const result2 = processDateTemplates(input2);
|
||||
const result3 = processDateTemplates(input3);
|
||||
|
||||
// Should leave malformed templates unchanged
|
||||
expect(result1).toBe("{{DATE:}}.md"); // Empty format should return original match
|
||||
expect(result2).toBe("{{DATE.md");
|
||||
expect(result3).toBe("DATE:YYYY-MM-DD}}.md");
|
||||
});
|
||||
});
|
||||
62
src/__tests__/dateUtil.test.ts
Normal file
62
src/__tests__/dateUtil.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { getTodayLocalDateString, getLocalDateString } from "../utils/dateUtil";
|
||||
|
||||
describe("dateUtil", () => {
|
||||
describe("getTodayLocalDateString", () => {
|
||||
test("should return today's date in YYYY-MM-DD format in local timezone", () => {
|
||||
const result = getTodayLocalDateString();
|
||||
const today = new Date();
|
||||
const expected = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
test("should not be affected by timezone differences", () => {
|
||||
// This test verifies that our function returns the local date
|
||||
// regardless of what toISOString() would return
|
||||
const result = getTodayLocalDateString();
|
||||
const today = new Date();
|
||||
const isoDate = today.toISOString().split('T')[0];
|
||||
|
||||
// The result should match the local date calculation
|
||||
const localDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
expect(result).toBe(localDate);
|
||||
|
||||
// Note: result might differ from isoDate if user is in a timezone ahead of UTC
|
||||
// and it's early morning, but that's the bug we're fixing
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalDateString", () => {
|
||||
test("should convert Date object to YYYY-MM-DD format in local timezone", () => {
|
||||
const testDate = new Date(2024, 0, 15); // January 15, 2024 (month is 0-indexed)
|
||||
const result = getLocalDateString(testDate);
|
||||
|
||||
expect(result).toBe("2024-01-15");
|
||||
});
|
||||
|
||||
test("should handle different dates correctly", () => {
|
||||
const testCases = [
|
||||
{ date: new Date(2024, 11, 31), expected: "2024-12-31" }, // December 31, 2024
|
||||
{ date: new Date(2023, 0, 1), expected: "2023-01-01" }, // January 1, 2023
|
||||
{ date: new Date(2024, 5, 15), expected: "2024-06-15" }, // June 15, 2024
|
||||
];
|
||||
|
||||
testCases.forEach(({ date, expected }) => {
|
||||
expect(getLocalDateString(date)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not be affected by timezone when converting local Date objects", () => {
|
||||
const testDate = new Date(2024, 0, 15, 10, 30, 0); // January 15, 2024, 10:30 AM local time
|
||||
const result = getLocalDateString(testDate);
|
||||
|
||||
// Should always return the local date part
|
||||
expect(result).toBe("2024-01-15");
|
||||
|
||||
// Verify it matches our manual calculation
|
||||
const expected = `${testDate.getFullYear()}-${String(testDate.getMonth() + 1).padStart(2, '0')}-${String(testDate.getDate()).padStart(2, '0')}`;
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/__tests__/debug.test.ts
Normal file
73
src/__tests__/debug.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Debug test for complex task parsing
|
||||
*/
|
||||
|
||||
import { MarkdownTaskParser } from "../utils/workers/ConfigurableTaskParser";
|
||||
import { getConfig } from "../common/task-parser-config";
|
||||
import { createMockPlugin } from "./mockUtils";
|
||||
|
||||
describe("Debug Complex Task Parsing", () => {
|
||||
test("debug complex task step by step", () => {
|
||||
const mockPlugin = createMockPlugin({
|
||||
preferMetadataFormat: "tasks",
|
||||
projectTagPrefix: {
|
||||
tasks: "project",
|
||||
dataview: "project",
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: "@",
|
||||
dataview: "context",
|
||||
},
|
||||
areaTagPrefix: {
|
||||
tasks: "area",
|
||||
dataview: "area",
|
||||
},
|
||||
});
|
||||
|
||||
const config = getConfig("tasks", mockPlugin);
|
||||
const parser = new MarkdownTaskParser(config);
|
||||
|
||||
const content =
|
||||
"- [ ] Complex task #project/work @office 📅 2024-12-31 🔺 #important #urgent 🔁 every week";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
console.log("Parsed task:", JSON.stringify(tasks[0], null, 2));
|
||||
console.log("Config specialTagPrefixes:", config.specialTagPrefixes);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Complex task");
|
||||
expect(tasks[0].metadata.project).toBe("work");
|
||||
expect(tasks[0].metadata.context).toBe("office");
|
||||
});
|
||||
|
||||
test("debug simple project tag", () => {
|
||||
const mockPlugin = createMockPlugin({
|
||||
preferMetadataFormat: "tasks",
|
||||
projectTagPrefix: {
|
||||
tasks: "project",
|
||||
dataview: "project",
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: "@",
|
||||
dataview: "context",
|
||||
},
|
||||
areaTagPrefix: {
|
||||
tasks: "area",
|
||||
dataview: "area",
|
||||
},
|
||||
});
|
||||
|
||||
const config = getConfig("tasks", mockPlugin);
|
||||
const parser = new MarkdownTaskParser(config);
|
||||
|
||||
const content = "- [ ] Simple task #project/work";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
console.log("Simple task:", JSON.stringify(tasks[0], null, 2));
|
||||
console.log("Config specialTagPrefixes:", config.specialTagPrefixes);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Simple task");
|
||||
expect(tasks[0].metadata.project).toBe("work");
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,13 @@ function createMockTask(
|
|||
completed,
|
||||
content: `Task at line ${lineNumber}`,
|
||||
tags: [],
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
project: "",
|
||||
context: "",
|
||||
priority: 0,
|
||||
},
|
||||
} as SortableTask;
|
||||
}
|
||||
|
||||
|
|
|
|||
127
src/__tests__/findParentWorkflow.test.ts
Normal file
127
src/__tests__/findParentWorkflow.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Text } from "@codemirror/state";
|
||||
import { findParentWorkflow } from "../editor-ext/workflow";
|
||||
|
||||
// Create a mock Text object for testing
|
||||
function createMockDoc(lines: string[]): Text {
|
||||
const mockDoc = {
|
||||
lines: lines.length,
|
||||
line: (lineNum: number) => {
|
||||
const lineIndex = lineNum - 1; // Convert to 0-indexed
|
||||
if (lineIndex < 0 || lineIndex >= lines.length) {
|
||||
throw new Error(`Line ${lineNum} out of bounds`);
|
||||
}
|
||||
return {
|
||||
number: lineNum,
|
||||
from: 0, // Simplified for testing
|
||||
to: lines[lineIndex].length,
|
||||
text: lines[lineIndex],
|
||||
};
|
||||
},
|
||||
} as Text;
|
||||
|
||||
return mockDoc;
|
||||
}
|
||||
|
||||
describe("findParentWorkflow", () => {
|
||||
test("should find workflow when project info is on first line with same indentation", () => {
|
||||
const lines = [
|
||||
"#workflow/development",
|
||||
"- [ ] Task 1 [stage::planning]",
|
||||
"- [ ] Task 2 [stage::development]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 2); // Looking for parent of line 2
|
||||
|
||||
expect(result).toBe("development");
|
||||
});
|
||||
|
||||
test("should find workflow when project info is on parent line with less indentation", () => {
|
||||
const lines = [
|
||||
"# Project",
|
||||
" #workflow/development",
|
||||
" - [ ] Task 1 [stage::planning]",
|
||||
" - [ ] Subtask [stage::development]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 4); // Looking for parent of line 4 (subtask)
|
||||
|
||||
expect(result).toBe("development");
|
||||
});
|
||||
|
||||
test("should find workflow when project info is on same indentation level but above", () => {
|
||||
const lines = [
|
||||
"#workflow/development",
|
||||
"",
|
||||
"- [ ] Task 1 [stage::planning]",
|
||||
"- [ ] Task 2 [stage::development]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 3); // Looking for parent of line 3
|
||||
|
||||
expect(result).toBe("development");
|
||||
});
|
||||
|
||||
test("should return null when no parent workflow is found", () => {
|
||||
const lines = [
|
||||
"# Project",
|
||||
"- [ ] Task 1 [stage::planning]",
|
||||
"- [ ] Task 2 [stage::development]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 2); // Looking for parent of line 2
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should not find workflow with greater indentation", () => {
|
||||
const lines = [
|
||||
"- [ ] Task 1",
|
||||
" #workflow/development",
|
||||
"- [ ] Task 2 [stage::planning]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 3); // Looking for parent of line 3
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle invalid line numbers", () => {
|
||||
const lines = [
|
||||
"#workflow/development",
|
||||
"- [ ] Task 1 [stage::planning]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result1 = findParentWorkflow(doc, 0); // Invalid line number
|
||||
const result2 = findParentWorkflow(doc, -1); // Invalid line number
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
test("should find closest parent workflow when multiple exist", () => {
|
||||
const lines = [
|
||||
"#workflow/project1",
|
||||
" #workflow/project2",
|
||||
" - [ ] Task 1 [stage::planning]",
|
||||
" - [ ] Subtask [stage::development]",
|
||||
];
|
||||
|
||||
const doc = createMockDoc(lines);
|
||||
|
||||
const result = findParentWorkflow(doc, 4); // Looking for parent of line 4 (subtask)
|
||||
|
||||
expect(result).toBe("project2"); // Should find the closest parent
|
||||
});
|
||||
});
|
||||
269
src/__tests__/ics-badge-integration.test.ts
Normal file
269
src/__tests__/ics-badge-integration.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Test ICS Badge Integration
|
||||
* Verifies that ICS events with badge showType are properly handled
|
||||
*/
|
||||
|
||||
import { IcsSource, IcsEvent, IcsTask } from "../types/ics";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
describe("ICS Badge Integration", () => {
|
||||
// Mock ICS source with badge showType
|
||||
const badgeSource: IcsSource = {
|
||||
id: "test-badge-source",
|
||||
name: "Test Badge Calendar",
|
||||
url: "https://example.com/calendar.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "badge", // This should display as badges
|
||||
color: "#ff6b6b",
|
||||
};
|
||||
|
||||
// Mock ICS source with event showType
|
||||
const eventSource: IcsSource = {
|
||||
id: "test-event-source",
|
||||
name: "Test Event Calendar",
|
||||
url: "https://example.com/calendar2.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event", // This should display as full events
|
||||
color: "#4ecdc4",
|
||||
};
|
||||
|
||||
// Mock ICS events
|
||||
const badgeEvent: IcsEvent = {
|
||||
uid: "badge-event-1",
|
||||
summary: "Badge Event 1",
|
||||
description: "This should appear as a badge",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
source: badgeSource,
|
||||
};
|
||||
|
||||
const eventEvent: IcsEvent = {
|
||||
uid: "event-event-1",
|
||||
summary: "Full Event 1",
|
||||
description: "This should appear as a full event",
|
||||
dtstart: new Date("2024-01-15T14:00:00Z"),
|
||||
dtend: new Date("2024-01-15T15:00:00Z"),
|
||||
allDay: false,
|
||||
source: eventSource,
|
||||
};
|
||||
|
||||
// Mock ICS tasks
|
||||
const badgeTask: IcsTask = {
|
||||
id: "ics-test-badge-source-badge-event-1",
|
||||
content: "Badge Event 1",
|
||||
filePath: "ics://Test Badge Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Badge Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: badgeEvent.dtstart.getTime(),
|
||||
dueDate: badgeEvent.dtend?.getTime(),
|
||||
scheduledDate: badgeEvent.dtstart.getTime(),
|
||||
project: badgeSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: badgeEvent,
|
||||
readonly: true,
|
||||
badge: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: badgeSource.name,
|
||||
id: badgeSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
const eventTask: IcsTask = {
|
||||
id: "ics-test-event-source-event-event-1",
|
||||
content: "Full Event 1",
|
||||
filePath: "ics://Test Event Calendar",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Full Event 1",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
startDate: eventEvent.dtstart.getTime(),
|
||||
dueDate: eventEvent.dtend?.getTime(),
|
||||
scheduledDate: eventEvent.dtstart.getTime(),
|
||||
project: eventSource.name,
|
||||
heading: [],
|
||||
},
|
||||
icsEvent: eventEvent,
|
||||
readonly: true,
|
||||
badge: true,
|
||||
source: {
|
||||
type: "ics",
|
||||
name: eventSource.name,
|
||||
id: eventSource.id,
|
||||
},
|
||||
};
|
||||
|
||||
test("should identify ICS tasks with badge showType", () => {
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
|
||||
// Simulate the logic from calendar component
|
||||
const badgeTasks = tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
return icsTask?.icsEvent?.source?.showType === "badge";
|
||||
});
|
||||
|
||||
const eventTasks = tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
return isIcsTask && icsTask?.icsEvent?.source?.showType !== "badge";
|
||||
});
|
||||
|
||||
expect(badgeTasks).toHaveLength(1);
|
||||
expect(badgeTasks[0].id).toBe(badgeTask.id);
|
||||
|
||||
expect(eventTasks).toHaveLength(1);
|
||||
expect(eventTasks[0].id).toBe(eventTask.id);
|
||||
});
|
||||
|
||||
test("should generate badge events for specific date", () => {
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
// Simulate getBadgeEventsForDate logic
|
||||
const badgeEvents: Map<
|
||||
string,
|
||||
{
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
const targetDateNormalized = new Date(targetDate);
|
||||
targetDateNormalized.setHours(0, 0, 0, 0);
|
||||
|
||||
// Check if the event is on the target date
|
||||
if (eventDate.getTime() === targetDateNormalized.getTime()) {
|
||||
const sourceId = icsTask.icsEvent.source.id;
|
||||
const existing = badgeEvents.get(sourceId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
badgeEvents.set(sourceId, {
|
||||
sourceId: sourceId,
|
||||
sourceName: icsTask.icsEvent.source.name,
|
||||
count: 1,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = Array.from(badgeEvents.values());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sourceId).toBe(badgeSource.id);
|
||||
expect(result[0].sourceName).toBe(badgeSource.name);
|
||||
expect(result[0].count).toBe(1);
|
||||
expect(result[0].color).toBe(badgeSource.color);
|
||||
});
|
||||
|
||||
test("should not include badge events in regular calendar events", () => {
|
||||
const tasks: Task[] = [badgeTask, eventTask];
|
||||
|
||||
// Simulate processTasks logic for filtering out badge events
|
||||
const calendarEvents = tasks.filter((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
// Skip ICS tasks with badge showType
|
||||
return !(isIcsTask && showAsBadge);
|
||||
});
|
||||
|
||||
expect(calendarEvents).toHaveLength(1);
|
||||
expect(calendarEvents[0].id).toBe(eventTask.id);
|
||||
});
|
||||
|
||||
test("should handle multiple badge events from same source", () => {
|
||||
// Create multiple badge events from the same source
|
||||
const badgeEvent2: IcsEvent = {
|
||||
...badgeEvent,
|
||||
uid: "badge-event-2",
|
||||
summary: "Badge Event 2",
|
||||
};
|
||||
|
||||
const badgeTask2: IcsTask = {
|
||||
...badgeTask,
|
||||
id: "ics-test-badge-source-badge-event-2",
|
||||
content: "Badge Event 2",
|
||||
icsEvent: badgeEvent2,
|
||||
};
|
||||
|
||||
const tasks: Task[] = [badgeTask, badgeTask2];
|
||||
const targetDate = new Date("2024-01-15");
|
||||
|
||||
// Simulate getBadgeEventsForDate logic
|
||||
const badgeEvents: Map<
|
||||
string,
|
||||
{
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
const targetDateNormalized = new Date(targetDate);
|
||||
targetDateNormalized.setHours(0, 0, 0, 0);
|
||||
|
||||
if (eventDate.getTime() === targetDateNormalized.getTime()) {
|
||||
const sourceId = icsTask.icsEvent.source.id;
|
||||
const existing = badgeEvents.get(sourceId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
badgeEvents.set(sourceId, {
|
||||
sourceId: sourceId,
|
||||
sourceName: icsTask.icsEvent.source.name,
|
||||
count: 1,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = Array.from(badgeEvents.values());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].count).toBe(2); // Should aggregate count from same source
|
||||
});
|
||||
});
|
||||
469
src/__tests__/ics-integration.test.ts
Normal file
469
src/__tests__/ics-integration.test.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* ICS Integration Tests
|
||||
* Tests for real-world ICS parsing using Chinese Lunar Calendar data
|
||||
*/
|
||||
|
||||
import { IcsParser } from "../utils/ics/IcsParser";
|
||||
import { IcsManager } from "../utils/ics/IcsManager";
|
||||
import { IcsSource, IcsManagerConfig } from "../types/ics";
|
||||
|
||||
// Mock Obsidian Component
|
||||
jest.mock("obsidian", () => ({
|
||||
Component: class MockComponent {
|
||||
constructor() {}
|
||||
load() {}
|
||||
unload() {}
|
||||
onload() {}
|
||||
onunload() {}
|
||||
addChild() {}
|
||||
removeChild() {}
|
||||
register() {}
|
||||
},
|
||||
requestUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Component for testing
|
||||
class MockComponent {
|
||||
constructor() {}
|
||||
load() {}
|
||||
unload() {}
|
||||
}
|
||||
|
||||
// Mock minimal settings for testing
|
||||
const mockPluginSettings = {
|
||||
taskStatusMarks: {
|
||||
"Not Started": " ",
|
||||
"In Progress": "/",
|
||||
Completed: "x",
|
||||
Abandoned: "-",
|
||||
Planned: "?",
|
||||
},
|
||||
} as any;
|
||||
|
||||
describe("ICS Integration with Chinese Lunar Calendar", () => {
|
||||
const testSource: IcsSource = {
|
||||
id: "chinese-lunar-test",
|
||||
name: "Chinese Lunar Calendar",
|
||||
url: "https://lwlsw.github.io/Chinese-Lunar-Calendar-ics/chinese_lunar_my.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
};
|
||||
|
||||
// Real sample data from the Chinese Lunar Calendar
|
||||
const realIcsData = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//infinet//Chinese Lunar Calendar//EN
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:中国农历
|
||||
X-WR-CALDESC:中国传统农历日历
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20191221T140102Z
|
||||
UID:2019-04-24-lc@infinet.github.io
|
||||
DTSTART;VALUE=DATE:20190424T235800
|
||||
DTEND;VALUE=DATE:20190424T235900
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:三月二十|三 4-24
|
||||
DESCRIPTION:农历三月二十日
|
||||
CATEGORIES:农历
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20191221T140102Z
|
||||
UID:2019-05-01-lc@infinet.github.io
|
||||
DTSTART;VALUE=DATE:20190501T000000
|
||||
DTEND;VALUE=DATE:20190501T235959
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:三月廿七|三 5-1
|
||||
DESCRIPTION:农历三月廿七日
|
||||
CATEGORIES:农历
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20191221T140102Z
|
||||
UID:2019-05-12-spring-festival@infinet.github.io
|
||||
DTSTART;VALUE=DATE:20190512T000000
|
||||
DTEND;VALUE=DATE:20190512T235959
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:四月初八|四 5-12 立夏
|
||||
DESCRIPTION:农历四月初八,立夏节气
|
||||
CATEGORIES:农历,节气
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20191221T140102Z
|
||||
UID:2019-06-07-dragon-boat@infinet.github.io
|
||||
DTSTART;VALUE=DATE:20190607T000000
|
||||
DTEND;VALUE=DATE:20190607T235959
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:五月初五|五 6-7 端午节
|
||||
DESCRIPTION:农历五月初五,端午节
|
||||
CATEGORIES:农历,节日
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20191221T140102Z
|
||||
UID:2019-09-13-mid-autumn@infinet.github.io
|
||||
DTSTART;VALUE=DATE:20190913T000000
|
||||
DTEND;VALUE=DATE:20190913T235959
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:八月十五|八 9-13 中秋节
|
||||
DESCRIPTION:农历八月十五,中秋节
|
||||
CATEGORIES:农历,节日
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
describe("Parser Integration", () => {
|
||||
test("should parse real Chinese Lunar Calendar data", () => {
|
||||
const result = IcsParser.parse(realIcsData, testSource);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.events.length).toBe(5);
|
||||
expect(result.errors).toBeDefined();
|
||||
|
||||
console.log(
|
||||
`Parsed ${result.events.length} events with ${result.errors.length} errors`
|
||||
);
|
||||
});
|
||||
|
||||
test("should extract calendar metadata correctly", () => {
|
||||
const result = IcsParser.parse(realIcsData, testSource);
|
||||
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata.calendarName).toBe("中国农历");
|
||||
expect(result.metadata.description).toBe("中国传统农历日历");
|
||||
expect(result.metadata.version).toBe("2.0");
|
||||
expect(result.metadata.prodid).toBe(
|
||||
"-//infinet//Chinese Lunar Calendar//EN"
|
||||
);
|
||||
});
|
||||
|
||||
test("should parse Chinese lunar events with correct format", () => {
|
||||
const result = IcsParser.parse(realIcsData, testSource);
|
||||
const events = result.events;
|
||||
|
||||
// Test first event (regular lunar date)
|
||||
const firstEvent = events[0];
|
||||
expect(firstEvent.uid).toBe("2019-04-24-lc@infinet.github.io");
|
||||
expect(firstEvent.summary).toBe("三月二十|三 4-24");
|
||||
expect(firstEvent.description).toBe("农历三月二十日");
|
||||
expect(firstEvent.categories).toEqual(["农历"]);
|
||||
expect(firstEvent.status).toBe("CONFIRMED");
|
||||
expect(firstEvent.source).toBe(testSource);
|
||||
|
||||
// Test festival event (端午节)
|
||||
const dragonBoatEvent = events.find((e) =>
|
||||
e.summary.includes("端午节")
|
||||
);
|
||||
expect(dragonBoatEvent).toBeDefined();
|
||||
expect(dragonBoatEvent!.summary).toBe("五月初五|五 6-7 端午节");
|
||||
expect(dragonBoatEvent!.description).toBe("农历五月初五,端午节");
|
||||
expect(dragonBoatEvent!.categories).toEqual(["农历", "节日"]);
|
||||
|
||||
// Test solar term event (立夏)
|
||||
const solarTermEvent = events.find((e) =>
|
||||
e.summary.includes("立夏")
|
||||
);
|
||||
expect(solarTermEvent).toBeDefined();
|
||||
expect(solarTermEvent!.categories).toEqual(["农历", "节气"]);
|
||||
});
|
||||
|
||||
test("should handle date parsing correctly", () => {
|
||||
const result = IcsParser.parse(realIcsData, testSource);
|
||||
const events = result.events;
|
||||
|
||||
events.forEach((event) => {
|
||||
expect(event.dtstart).toBeInstanceOf(Date);
|
||||
expect(event.dtstart.getTime()).not.toBeNaN();
|
||||
|
||||
if (event.dtend) {
|
||||
expect(event.dtend).toBeInstanceOf(Date);
|
||||
expect(event.dtend.getTime()).not.toBeNaN();
|
||||
expect(event.dtend.getTime()).toBeGreaterThanOrEqual(
|
||||
event.dtstart.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Event: ${event.summary} on ${event.dtstart.toDateString()}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should identify all-day events correctly", () => {
|
||||
const result = IcsParser.parse(realIcsData, testSource);
|
||||
const events = result.events;
|
||||
|
||||
// Most Chinese lunar calendar events should be all-day
|
||||
const allDayEvents = events.filter((event) => event.allDay);
|
||||
const timedEvents = events.filter((event) => !event.allDay);
|
||||
|
||||
console.log(
|
||||
`All-day events: ${allDayEvents.length}, Timed events: ${timedEvents.length}`
|
||||
);
|
||||
|
||||
// Expect most events to be all-day for lunar calendar
|
||||
expect(allDayEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Manager Integration", () => {
|
||||
let icsManager: IcsManager;
|
||||
let mockComponent: MockComponent;
|
||||
|
||||
const testConfig: IcsManagerConfig = {
|
||||
sources: [testSource],
|
||||
enableBackgroundRefresh: false,
|
||||
globalRefreshInterval: 60,
|
||||
maxCacheAge: 24,
|
||||
networkTimeout: 30,
|
||||
maxEventsPerSource: 1000,
|
||||
showInCalendar: true,
|
||||
showInTaskLists: true,
|
||||
defaultEventColor: "#3b82f6",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockComponent = new MockComponent();
|
||||
icsManager = new IcsManager(testConfig, mockPluginSettings);
|
||||
await icsManager.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (icsManager) {
|
||||
icsManager.unload();
|
||||
}
|
||||
});
|
||||
|
||||
test("should fetch and parse real Chinese Lunar Calendar", async () => {
|
||||
try {
|
||||
const result = await icsManager.syncSource(testSource.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
|
||||
if (result.data) {
|
||||
expect(result.data.events.length).toBeGreaterThan(0);
|
||||
console.log(
|
||||
`Fetched ${result.data.events.length} events from real Chinese Lunar Calendar`
|
||||
);
|
||||
|
||||
// Check for typical Chinese lunar calendar content
|
||||
const events = result.data.events;
|
||||
const lunarEvents = events.filter(
|
||||
(event) =>
|
||||
event.summary.includes("月") ||
|
||||
event.summary.includes("初") ||
|
||||
event.summary.includes("十") ||
|
||||
event.categories?.includes("农历")
|
||||
);
|
||||
|
||||
expect(lunarEvents.length).toBeGreaterThan(0);
|
||||
console.log(
|
||||
`Found ${lunarEvents.length} lunar calendar events`
|
||||
);
|
||||
|
||||
// Look for festivals
|
||||
const festivals = events.filter(
|
||||
(event) =>
|
||||
event.summary.includes("春节") ||
|
||||
event.summary.includes("中秋") ||
|
||||
event.summary.includes("端午") ||
|
||||
event.summary.includes("元宵") ||
|
||||
event.categories?.includes("节日")
|
||||
);
|
||||
|
||||
if (festivals.length > 0) {
|
||||
console.log(
|
||||
`Found ${festivals.length} festival events`
|
||||
);
|
||||
festivals.slice(0, 3).forEach((festival) => {
|
||||
console.log(`Festival: ${festival.summary}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Network test failed, this is expected in some environments:",
|
||||
error
|
||||
);
|
||||
// Don't fail the test if network is unavailable
|
||||
}
|
||||
}, 15000); // 15 second timeout for network request
|
||||
|
||||
test("should convert events to tasks correctly", async () => {
|
||||
// Use mock data for reliable testing
|
||||
const parseResult = IcsParser.parse(realIcsData, testSource);
|
||||
const tasks = icsManager.convertEventsToTasks(parseResult.events);
|
||||
|
||||
expect(tasks).toHaveLength(parseResult.events.length);
|
||||
|
||||
tasks.forEach((task) => {
|
||||
expect(task.readonly).toBe(true);
|
||||
expect(task.content).toBeDefined();
|
||||
expect(task.source.type).toBe("ics");
|
||||
expect(task.source.id).toBe(testSource.id);
|
||||
expect(task.icsEvent).toBeDefined();
|
||||
|
||||
// Check metadata mapping
|
||||
expect(task.metadata.startDate).toBeDefined();
|
||||
expect(task.metadata.project).toBe(testSource.name);
|
||||
|
||||
if (task.icsEvent.categories) {
|
||||
expect(task.metadata.tags).toEqual(
|
||||
task.icsEvent.categories
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Sample converted tasks:");
|
||||
tasks.slice(0, 3).forEach((task, index) => {
|
||||
console.log(
|
||||
`Task ${index + 1}: ${task.content} (${
|
||||
task.icsEvent.summary
|
||||
})`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle event filtering", () => {
|
||||
const parseResult = IcsParser.parse(realIcsData, testSource);
|
||||
const allEvents = parseResult.events;
|
||||
|
||||
// Test filtering by categories
|
||||
const festivalEvents = allEvents.filter((event) =>
|
||||
event.categories?.includes("节日")
|
||||
);
|
||||
|
||||
const solarTermEvents = allEvents.filter((event) =>
|
||||
event.categories?.includes("节气")
|
||||
);
|
||||
|
||||
const regularLunarEvents = allEvents.filter(
|
||||
(event) =>
|
||||
event.categories?.includes("农历") &&
|
||||
!event.categories?.includes("节日") &&
|
||||
!event.categories?.includes("节气")
|
||||
);
|
||||
|
||||
console.log(`Festival events: ${festivalEvents.length}`);
|
||||
console.log(`Solar term events: ${solarTermEvents.length}`);
|
||||
console.log(`Regular lunar events: ${regularLunarEvents.length}`);
|
||||
|
||||
expect(
|
||||
festivalEvents.length +
|
||||
solarTermEvents.length +
|
||||
regularLunarEvents.length
|
||||
).toBeLessThanOrEqual(allEvents.length);
|
||||
});
|
||||
|
||||
test("should handle sync status correctly", async () => {
|
||||
const initialStatus = icsManager.getSyncStatus(testSource.id);
|
||||
expect(initialStatus).toBeDefined();
|
||||
expect(initialStatus?.sourceId).toBe(testSource.id);
|
||||
expect(initialStatus?.status).toBe("idle");
|
||||
|
||||
// Test sync status during operation
|
||||
const syncPromise = icsManager.syncSource(testSource.id);
|
||||
|
||||
// Check if status changes to syncing (might be too fast to catch)
|
||||
const syncingStatus = icsManager.getSyncStatus(testSource.id);
|
||||
expect(syncingStatus).toBeDefined();
|
||||
|
||||
try {
|
||||
await syncPromise;
|
||||
|
||||
const finalStatus = icsManager.getSyncStatus(testSource.id);
|
||||
expect(finalStatus?.status).toMatch(/idle|error/);
|
||||
|
||||
if (finalStatus?.status === "idle") {
|
||||
expect(finalStatus.eventCount).toBeGreaterThan(0);
|
||||
expect(finalStatus.lastSync).toBeDefined();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorStatus = icsManager.getSyncStatus(testSource.id);
|
||||
expect(errorStatus?.status).toBe("error");
|
||||
expect(errorStatus?.error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("should handle malformed Chinese lunar data", () => {
|
||||
const malformedData = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//infinet//Chinese Lunar Calendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:malformed-event
|
||||
DTSTART:INVALID_DATE_FORMAT
|
||||
SUMMARY:三月二十|三 INVALID
|
||||
CATEGORIES:农历
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const result = IcsParser.parse(malformedData, testSource);
|
||||
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.errors).toBeDefined();
|
||||
|
||||
// Parser should handle malformed data gracefully
|
||||
// Either by excluding invalid events or including them with default dates
|
||||
expect(result.events.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
console.log(
|
||||
`Malformed data produced ${result.errors.length} errors and ${result.events.length} valid events`
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle missing required fields", () => {
|
||||
const incompleteData = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:incomplete-event
|
||||
SUMMARY:三月二十|三
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const result = IcsParser.parse(incompleteData, testSource);
|
||||
|
||||
// Event without DTSTART should not be included
|
||||
expect(result.events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
test("should parse large Chinese lunar dataset efficiently", () => {
|
||||
// Create a larger dataset by repeating the sample data
|
||||
const largeDataset = Array(100)
|
||||
.fill(
|
||||
realIcsData
|
||||
.replace(
|
||||
/BEGIN:VCALENDAR[\s\S]*?BEGIN:VEVENT/,
|
||||
"BEGIN:VEVENT"
|
||||
)
|
||||
.replace(/END:VCALENDAR/, "")
|
||||
)
|
||||
.join("\n");
|
||||
const fullLargeData = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//infinet//Chinese Lunar Calendar//EN
|
||||
${largeDataset}
|
||||
END:VCALENDAR`;
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(fullLargeData, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
const parseTime = endTime - startTime;
|
||||
console.log(
|
||||
`Parsing ${
|
||||
result.events.length
|
||||
} events took ${parseTime.toFixed(2)}ms`
|
||||
);
|
||||
|
||||
// Should parse within reasonable time
|
||||
expect(parseTime).toBeLessThan(1000); // 1 second max for this test size
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
664
src/__tests__/ics-manager.test.ts
Normal file
664
src/__tests__/ics-manager.test.ts
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
/**
|
||||
* ICS Manager Tests
|
||||
* Tests for managing ICS calendar sources and fetching data
|
||||
*/
|
||||
|
||||
import { IcsManager } from "../utils/ics/IcsManager";
|
||||
import { IcsSource, IcsManagerConfig } from "../types/ics";
|
||||
|
||||
// Mock minimal settings for testing
|
||||
const mockSettings = {
|
||||
taskStatusMarks: {
|
||||
"Not Started": " ",
|
||||
"In Progress": "/",
|
||||
Completed: "x",
|
||||
Abandoned: "-",
|
||||
Planned: "?",
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Mock Obsidian Component
|
||||
jest.mock("obsidian", () => ({
|
||||
Component: class MockComponent {
|
||||
constructor() {}
|
||||
load() {}
|
||||
unload() {}
|
||||
onload() {}
|
||||
onunload() {}
|
||||
addChild() {}
|
||||
removeChild() {}
|
||||
register() {}
|
||||
},
|
||||
requestUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Component for testing
|
||||
class MockComponent {
|
||||
constructor() {}
|
||||
load() {}
|
||||
unload() {}
|
||||
}
|
||||
|
||||
describe("ICS Manager", () => {
|
||||
let icsManager: IcsManager;
|
||||
let mockComponent: MockComponent;
|
||||
|
||||
const testConfig: IcsManagerConfig = {
|
||||
sources: [
|
||||
{
|
||||
id: "chinese-lunar",
|
||||
name: "Chinese Lunar Calendar",
|
||||
url: "https://lwlsw.github.io/Chinese-Lunar-Calendar-ics/chinese_lunar_my.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
},
|
||||
],
|
||||
enableBackgroundRefresh: false, // Disable for testing
|
||||
globalRefreshInterval: 60,
|
||||
maxCacheAge: 24,
|
||||
networkTimeout: 30,
|
||||
maxEventsPerSource: 1000,
|
||||
showInCalendar: true,
|
||||
showInTaskLists: true,
|
||||
defaultEventColor: "#3b82f6",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockComponent = new MockComponent();
|
||||
icsManager = new IcsManager(testConfig, mockSettings);
|
||||
await icsManager.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (icsManager) {
|
||||
icsManager.unload();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
test("should initialize with config", () => {
|
||||
expect(icsManager).toBeDefined();
|
||||
});
|
||||
|
||||
test("should update config", () => {
|
||||
const newConfig = {
|
||||
...testConfig,
|
||||
globalRefreshInterval: 120,
|
||||
};
|
||||
|
||||
icsManager.updateConfig(newConfig);
|
||||
// Test that config was updated by checking sync status
|
||||
const syncStatus = icsManager.getSyncStatus(
|
||||
testConfig.sources[0].id
|
||||
);
|
||||
expect(syncStatus).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Source Management", () => {
|
||||
test("should manage sync statuses", () => {
|
||||
const syncStatus = icsManager.getSyncStatus(
|
||||
testConfig.sources[0].id
|
||||
);
|
||||
expect(syncStatus).toBeDefined();
|
||||
expect(syncStatus?.sourceId).toBe(testConfig.sources[0].id);
|
||||
});
|
||||
|
||||
test("should get all sync statuses", () => {
|
||||
const allStatuses = icsManager.getAllSyncStatuses();
|
||||
expect(allStatuses.size).toBe(1);
|
||||
expect(allStatuses.has(testConfig.sources[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle disabled sources", () => {
|
||||
const configWithDisabled = {
|
||||
...testConfig,
|
||||
sources: [
|
||||
...testConfig.sources,
|
||||
{
|
||||
id: "disabled-source",
|
||||
name: "Disabled Source",
|
||||
url: "https://example.com/disabled.ics",
|
||||
enabled: false,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
icsManager.updateConfig(configWithDisabled);
|
||||
|
||||
const allStatuses = icsManager.getAllSyncStatuses();
|
||||
expect(allStatuses.size).toBe(2);
|
||||
|
||||
const disabledStatus = icsManager.getSyncStatus("disabled-source");
|
||||
expect(disabledStatus?.status).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Fetching", () => {
|
||||
test("should handle sync source", async () => {
|
||||
const source = testConfig.sources[0];
|
||||
|
||||
try {
|
||||
const result = await icsManager.syncSource(source.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
|
||||
if (result.data) {
|
||||
expect(result.data.events.length).toBeGreaterThan(0);
|
||||
console.log(
|
||||
`Fetched ${result.data.events.length} events from Chinese Lunar Calendar`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Network test failed, this is expected in some environments:",
|
||||
error
|
||||
);
|
||||
// Don't fail the test if network is unavailable
|
||||
}
|
||||
}, 10000); // 10 second timeout for network request
|
||||
|
||||
test("should handle network errors gracefully", async () => {
|
||||
const invalidConfig = {
|
||||
...testConfig,
|
||||
sources: [
|
||||
{
|
||||
id: "invalid-source",
|
||||
name: "Invalid Source",
|
||||
url: "https://invalid-url-that-does-not-exist.com/calendar.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
icsManager.updateConfig(invalidConfig);
|
||||
const result = await icsManager.syncSource("invalid-source");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event Management", () => {
|
||||
test("should get all events", () => {
|
||||
const events = icsManager.getAllEvents();
|
||||
expect(Array.isArray(events)).toBe(true);
|
||||
});
|
||||
|
||||
test("should get events from specific source", () => {
|
||||
const events = icsManager.getEventsFromSource(
|
||||
testConfig.sources[0].id
|
||||
);
|
||||
expect(Array.isArray(events)).toBe(true);
|
||||
});
|
||||
|
||||
test("should convert events to tasks", () => {
|
||||
const mockEvents: any[] = []; // Empty array for testing
|
||||
const tasks = icsManager.convertEventsToTasks(mockEvents);
|
||||
expect(Array.isArray(tasks)).toBe(true);
|
||||
expect(tasks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Replacement", () => {
|
||||
test("should apply text replacements to event summary", () => {
|
||||
// Create a test source with text replacement rules
|
||||
const sourceWithReplacements: IcsSource = {
|
||||
id: "test-replacement",
|
||||
name: "Test Replacement Source",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "remove-prefix",
|
||||
name: "Remove Meeting Prefix",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "^Meeting: ",
|
||||
replacement: "",
|
||||
flags: "g",
|
||||
},
|
||||
{
|
||||
id: "replace-location",
|
||||
name: "Replace Room Numbers",
|
||||
enabled: true,
|
||||
target: "location",
|
||||
pattern: "Room (\\d+)",
|
||||
replacement: "Conference Room $1",
|
||||
flags: "gi",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Create a mock event
|
||||
const mockEvent = {
|
||||
uid: "test-event-1",
|
||||
summary: "Meeting: Weekly Standup",
|
||||
description: "Team standup meeting",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
dtend: new Date("2024-01-15T11:00:00Z"),
|
||||
allDay: false,
|
||||
location: "Room 101",
|
||||
source: sourceWithReplacements,
|
||||
};
|
||||
|
||||
// Create a manager with the test source
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithReplacements],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
// Convert event to task (this will apply text replacements)
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// Verify replacements were applied
|
||||
expect(task.content).toBe("Weekly Standup"); // "Meeting: " prefix removed
|
||||
expect(task.metadata.context).toBe("Conference Room 101"); // "Room 101" -> "Conference Room 101"
|
||||
expect(task.icsEvent.summary).toBe("Weekly Standup");
|
||||
expect(task.icsEvent.location).toBe("Conference Room 101");
|
||||
});
|
||||
|
||||
test("should apply multiple replacements in sequence", () => {
|
||||
const sourceWithMultipleReplacements: IcsSource = {
|
||||
id: "test-multiple",
|
||||
name: "Test Multiple Replacements",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "replace-1",
|
||||
name: "First Replacement",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "URGENT",
|
||||
replacement: "Important",
|
||||
flags: "gi",
|
||||
},
|
||||
{
|
||||
id: "replace-2",
|
||||
name: "Second Replacement",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "Important",
|
||||
replacement: "High Priority",
|
||||
flags: "g",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-2",
|
||||
summary: "URGENT: System Maintenance",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithMultipleReplacements,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithMultipleReplacements],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// Should apply both replacements in sequence: URGENT -> Important -> High Priority
|
||||
expect(task.content).toBe("High Priority: System Maintenance");
|
||||
});
|
||||
|
||||
test("should apply replacements to all fields when target is 'all'", () => {
|
||||
const sourceWithAllTarget: IcsSource = {
|
||||
id: "test-all-target",
|
||||
name: "Test All Target",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "replace-all",
|
||||
name: "Replace All Occurrences",
|
||||
enabled: true,
|
||||
target: "all",
|
||||
pattern: "old",
|
||||
replacement: "new",
|
||||
flags: "gi",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-3",
|
||||
summary: "Old Meeting in Old Room",
|
||||
description: "This is an old description",
|
||||
location: "Old Building",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithAllTarget,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithAllTarget],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// All fields should have "old" replaced with "new"
|
||||
expect(task.content).toBe("new Meeting in new Room");
|
||||
expect(task.icsEvent.description).toBe(
|
||||
"This is an new description"
|
||||
);
|
||||
expect(task.icsEvent.location).toBe("new Building");
|
||||
});
|
||||
|
||||
test("should skip disabled replacement rules", () => {
|
||||
const sourceWithDisabledRule: IcsSource = {
|
||||
id: "test-disabled",
|
||||
name: "Test Disabled Rule",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "disabled-rule",
|
||||
name: "Disabled Rule",
|
||||
enabled: false, // This rule is disabled
|
||||
target: "summary",
|
||||
pattern: "Test",
|
||||
replacement: "Demo",
|
||||
flags: "g",
|
||||
},
|
||||
{
|
||||
id: "enabled-rule",
|
||||
name: "Enabled Rule",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "Meeting",
|
||||
replacement: "Session",
|
||||
flags: "g",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-4",
|
||||
summary: "Test Meeting",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithDisabledRule,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithDisabledRule],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// Only the enabled rule should be applied
|
||||
expect(task.content).toBe("Test Session"); // "Meeting" -> "Session", but "Test" unchanged
|
||||
});
|
||||
|
||||
test("should handle invalid regex patterns gracefully", () => {
|
||||
const sourceWithInvalidRegex: IcsSource = {
|
||||
id: "test-invalid-regex",
|
||||
name: "Test Invalid Regex",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "invalid-regex",
|
||||
name: "Invalid Regex",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "[invalid regex", // Invalid regex pattern
|
||||
replacement: "replaced",
|
||||
flags: "g",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-5",
|
||||
summary: "Original Text",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithInvalidRegex,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithInvalidRegex],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
// Should not throw an error, and text should remain unchanged
|
||||
expect(() => {
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
expect(task.content).toBe("Original Text"); // Should remain unchanged
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should work with capture groups in replacement", () => {
|
||||
const sourceWithCaptureGroups: IcsSource = {
|
||||
id: "test-capture-groups",
|
||||
name: "Test Capture Groups",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
textReplacements: [
|
||||
{
|
||||
id: "capture-groups",
|
||||
name: "Use Capture Groups",
|
||||
enabled: true,
|
||||
target: "summary",
|
||||
pattern: "(\\w+) Meeting with (\\w+)",
|
||||
replacement: "$2 and $1 Discussion",
|
||||
flags: "g",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-6",
|
||||
summary: "Weekly Meeting with John",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithCaptureGroups,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithCaptureGroups],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// Should swap the captured groups
|
||||
expect(task.content).toBe("John and Weekly Discussion");
|
||||
});
|
||||
|
||||
test("should handle events without text replacements", () => {
|
||||
const sourceWithoutReplacements: IcsSource = {
|
||||
id: "test-no-replacements",
|
||||
name: "Test No Replacements",
|
||||
url: "https://example.com/test.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
// No textReplacements property
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
uid: "test-event-7",
|
||||
summary: "Original Summary",
|
||||
description: "Original Description",
|
||||
location: "Original Location",
|
||||
dtstart: new Date("2024-01-15T10:00:00Z"),
|
||||
allDay: false,
|
||||
source: sourceWithoutReplacements,
|
||||
};
|
||||
|
||||
const testManager = new IcsManager(
|
||||
{
|
||||
...testConfig,
|
||||
sources: [sourceWithoutReplacements],
|
||||
},
|
||||
mockSettings
|
||||
);
|
||||
|
||||
const task = testManager.convertEventsToTasks([mockEvent])[0];
|
||||
|
||||
// Text should remain unchanged
|
||||
expect(task.content).toBe("Original Summary");
|
||||
expect(task.icsEvent.description).toBe("Original Description");
|
||||
expect(task.icsEvent.location).toBe("Original Location");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache Management", () => {
|
||||
test("should clear source cache", () => {
|
||||
icsManager.clearSourceCache(testConfig.sources[0].id);
|
||||
// Should not throw error
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("should clear all cache", () => {
|
||||
icsManager.clearAllCache();
|
||||
// Should not throw error
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Background Refresh", () => {
|
||||
test("should handle background refresh configuration", () => {
|
||||
// Test that background refresh is disabled in test config
|
||||
expect(testConfig.enableBackgroundRefresh).toBe(false);
|
||||
|
||||
// Enable background refresh
|
||||
const newConfig = {
|
||||
...testConfig,
|
||||
enableBackgroundRefresh: true,
|
||||
};
|
||||
|
||||
icsManager.updateConfig(newConfig);
|
||||
// Should not throw error
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test for real-world usage
|
||||
*/
|
||||
describe("ICS Manager Integration", () => {
|
||||
test("should work end-to-end with Chinese Lunar Calendar", async () => {
|
||||
const config: IcsManagerConfig = {
|
||||
sources: [
|
||||
{
|
||||
id: "integration-test",
|
||||
name: "Integration Test Calendar",
|
||||
url: "https://lwlsw.github.io/Chinese-Lunar-Calendar-ics/chinese_lunar_my.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event" as const,
|
||||
},
|
||||
],
|
||||
enableBackgroundRefresh: false, // Disable for testing
|
||||
globalRefreshInterval: 60,
|
||||
maxCacheAge: 24,
|
||||
networkTimeout: 30,
|
||||
maxEventsPerSource: 100, // Limit for testing
|
||||
showInCalendar: true,
|
||||
showInTaskLists: true,
|
||||
defaultEventColor: "#3b82f6",
|
||||
};
|
||||
|
||||
const manager = new IcsManager(config, mockSettings);
|
||||
await manager.initialize();
|
||||
|
||||
try {
|
||||
// Test the complete workflow
|
||||
const result = await manager.syncSource(config.sources[0].id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
expect(result.data.events.length).toBeGreaterThan(0);
|
||||
expect(result.data.events.length).toBeLessThanOrEqual(100); // Respects limit
|
||||
|
||||
// Convert to tasks
|
||||
const tasks = manager.convertEventsToTasks(result.data.events);
|
||||
expect(tasks).toHaveLength(result.data.events.length);
|
||||
|
||||
// All tasks should be readonly
|
||||
tasks.forEach((task) => {
|
||||
expect(task.readonly).toBe(true);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Integration test successful: ${result.data.events.length} events, ${tasks.length} tasks`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Integration test failed due to network issues:",
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
manager.unload();
|
||||
}
|
||||
}, 15000); // 15 second timeout for integration test
|
||||
});
|
||||
178
src/__tests__/ics-parser-performance.test.ts
Normal file
178
src/__tests__/ics-parser-performance.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* ICS Parser Performance Tests
|
||||
* Tests for optimized parsing performance
|
||||
*/
|
||||
|
||||
import { IcsParser } from "../utils/ics/IcsParser";
|
||||
import { IcsSource } from "../types/ics";
|
||||
|
||||
describe("ICS Parser Performance", () => {
|
||||
const testSource: IcsSource = {
|
||||
id: "test-performance",
|
||||
name: "Performance Test",
|
||||
url: "test://performance",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
};
|
||||
|
||||
// Mock ICS content with multiple events
|
||||
const createMockIcsContent = (eventCount: number): string => {
|
||||
const events = [];
|
||||
for (let i = 0; i < eventCount; i++) {
|
||||
const date = new Date(2024, 0, i + 1);
|
||||
const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
events.push(`BEGIN:VEVENT
|
||||
UID:event-${i}@test.com
|
||||
DTSTART;VALUE=DATE:${dateStr}
|
||||
DTEND;VALUE=DATE:${dateStr}
|
||||
SUMMARY:Test Event ${i}
|
||||
DESCRIPTION:This is test event number ${i}
|
||||
LOCATION:Test Location ${i}
|
||||
STATUS:CONFIRMED
|
||||
CATEGORIES:test,performance
|
||||
PRIORITY:5
|
||||
CREATED:20240101T120000Z
|
||||
LAST-MODIFIED:20240101T120000Z
|
||||
END:VEVENT`);
|
||||
}
|
||||
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test Calendar//EN
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:Performance Test Calendar
|
||||
X-WR-CALDESC:Calendar for performance testing
|
||||
${events.join('\n')}
|
||||
END:VCALENDAR`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache before each test
|
||||
IcsParser.clearCache();
|
||||
});
|
||||
|
||||
describe("Parsing Performance", () => {
|
||||
test("should parse small ICS content quickly", () => {
|
||||
const content = createMockIcsContent(10);
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(content, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
const parseTime = endTime - startTime;
|
||||
|
||||
expect(result.events).toHaveLength(10);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(parseTime).toBeLessThan(50); // Should be very fast for small content
|
||||
|
||||
console.log(`Small content (10 events) parsing took ${parseTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
test("should parse medium ICS content efficiently", () => {
|
||||
const content = createMockIcsContent(100);
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(content, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
const parseTime = endTime - startTime;
|
||||
|
||||
expect(result.events).toHaveLength(100);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(parseTime).toBeLessThan(200); // Should be fast for medium content
|
||||
|
||||
console.log(`Medium content (100 events) parsing took ${parseTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
test("should parse large ICS content within reasonable time", () => {
|
||||
const content = createMockIcsContent(1000);
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(content, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
const parseTime = endTime - startTime;
|
||||
|
||||
expect(result.events).toHaveLength(1000);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(parseTime).toBeLessThan(1000); // Should be under 1 second for large content
|
||||
|
||||
console.log(`Large content (1000 events) parsing took ${parseTime.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching Performance", () => {
|
||||
test("should benefit from caching on repeated parsing", () => {
|
||||
const content = createMockIcsContent(100);
|
||||
|
||||
// First parse (no cache)
|
||||
const startTime1 = performance.now();
|
||||
const result1 = IcsParser.parse(content, testSource);
|
||||
const endTime1 = performance.now();
|
||||
const firstParseTime = endTime1 - startTime1;
|
||||
|
||||
// Second parse (with cache)
|
||||
const startTime2 = performance.now();
|
||||
const result2 = IcsParser.parse(content, testSource);
|
||||
const endTime2 = performance.now();
|
||||
const secondParseTime = endTime2 - startTime2;
|
||||
|
||||
expect(result1.events).toHaveLength(100);
|
||||
expect(result2.events).toHaveLength(100);
|
||||
expect(secondParseTime).toBeLessThan(firstParseTime * 0.5); // Cache should be at least 50% faster
|
||||
|
||||
console.log(`First parse: ${firstParseTime.toFixed(2)}ms, Cached parse: ${secondParseTime.toFixed(2)}ms`);
|
||||
console.log(`Cache speedup: ${(firstParseTime / secondParseTime).toFixed(2)}x`);
|
||||
});
|
||||
|
||||
test("should manage cache size properly", () => {
|
||||
const cacheStatsBefore = IcsParser.getCacheStats();
|
||||
expect(cacheStatsBefore.size).toBe(0);
|
||||
|
||||
// Parse multiple different contents to fill cache
|
||||
for (let i = 0; i < 60; i++) { // More than MAX_CACHE_SIZE (50)
|
||||
const content = createMockIcsContent(5) + `\n<!-- unique-${i} -->`;
|
||||
IcsParser.parse(content, { ...testSource, id: `test-${i}` });
|
||||
}
|
||||
|
||||
const cacheStatsAfter = IcsParser.getCacheStats();
|
||||
expect(cacheStatsAfter.size).toBeLessThanOrEqual(cacheStatsAfter.maxSize);
|
||||
|
||||
console.log(`Cache size after filling: ${cacheStatsAfter.size}/${cacheStatsAfter.maxSize}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory Efficiency", () => {
|
||||
test("should handle string operations efficiently", () => {
|
||||
// Test with content that has many folded lines
|
||||
const foldedContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test Calendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:folded-test@test.com
|
||||
DTSTART:20240101T120000Z
|
||||
SUMMARY:This is a very long summary that will be folded across multiple
|
||||
lines to test the unfolding optimization and ensure it works correctly
|
||||
with the new array-based approach instead of string concatenation
|
||||
DESCRIPTION:This is also a very long description that spans multiple
|
||||
lines and contains various escape sequences like \\n newlines and \\,
|
||||
commas and \\; semicolons to test the unescaping optimization
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(foldedContent, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].summary).toContain("folded across multiple lines");
|
||||
expect(result.events[0].description).toContain("newlines");
|
||||
|
||||
console.log(`Folded content parsing took ${(endTime - startTime).toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
});
|
||||
296
src/__tests__/ics-parser.test.ts
Normal file
296
src/__tests__/ics-parser.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* ICS Parser Tests
|
||||
* Tests for parsing Chinese Lunar Calendar ICS data
|
||||
*/
|
||||
|
||||
import { IcsParser } from "../utils/ics/IcsParser";
|
||||
import { IcsSource, IcsEvent } from "../types/ics";
|
||||
|
||||
describe("ICS Parser", () => {
|
||||
const testSource: IcsSource = {
|
||||
id: "test-chinese-lunar",
|
||||
name: "Chinese Lunar Calendar Test",
|
||||
url: "https://lwlsw.github.io/Chinese-Lunar-Calendar-ics/chinese_lunar_my.ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
showType: "event",
|
||||
};
|
||||
|
||||
let icsContent: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Fetch the actual ICS content for testing
|
||||
try {
|
||||
const response = await fetch(testSource.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${response.status}: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
icsContent = await response.text();
|
||||
console.log(`Fetched ICS content: ${icsContent.length} characters`);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch ICS content, using mock data");
|
||||
icsContent = getMockIcsContent();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Basic Parsing", () => {
|
||||
test("should parse ICS content without errors", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.metadata).toBeDefined();
|
||||
|
||||
console.log(
|
||||
`Parsed ${result.events.length} events with ${result.errors.length} errors`
|
||||
);
|
||||
});
|
||||
|
||||
test("should extract calendar metadata", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
expect(result.metadata).toBeDefined();
|
||||
// Chinese Lunar Calendar should have some metadata
|
||||
if (result.metadata.calendarName) {
|
||||
expect(typeof result.metadata.calendarName).toBe("string");
|
||||
}
|
||||
if (result.metadata.version) {
|
||||
expect(result.metadata.version).toBe("2.0");
|
||||
}
|
||||
});
|
||||
|
||||
test("should parse events with required fields", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
|
||||
// Check first few events have required fields
|
||||
const sampleEvents = result.events.slice(0, 5);
|
||||
sampleEvents.forEach((event, index) => {
|
||||
expect(event.uid).toBeDefined();
|
||||
expect(event.summary).toBeDefined();
|
||||
expect(event.dtstart).toBeDefined();
|
||||
expect(event.source).toBe(testSource);
|
||||
|
||||
console.log(
|
||||
`Event ${index + 1}: ${
|
||||
event.summary
|
||||
} on ${event.dtstart.toDateString()}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chinese Lunar Calendar Specific Tests", () => {
|
||||
test("should parse lunar festival events", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
// Look for common Chinese festivals
|
||||
const festivals = result.events.filter(
|
||||
(event) =>
|
||||
event.summary.includes("春节") ||
|
||||
event.summary.includes("中秋") ||
|
||||
event.summary.includes("端午") ||
|
||||
event.summary.includes("元宵") ||
|
||||
event.summary.includes("七夕") ||
|
||||
event.summary.includes("重阳")
|
||||
);
|
||||
|
||||
expect(festivals.length).toBeGreaterThan(0);
|
||||
console.log(`Found ${festivals.length} Chinese festival events`);
|
||||
|
||||
festivals.slice(0, 3).forEach((festival) => {
|
||||
console.log(
|
||||
`Festival: ${
|
||||
festival.summary
|
||||
} on ${festival.dtstart.toDateString()}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse lunar month events", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
// Look for lunar month indicators
|
||||
const lunarEvents = result.events.filter(
|
||||
(event) =>
|
||||
event.summary.includes("农历") ||
|
||||
event.summary.includes("正月") ||
|
||||
event.summary.includes("二月") ||
|
||||
event.summary.includes("三月") ||
|
||||
event.summary.includes("初一") ||
|
||||
event.summary.includes("十五")
|
||||
);
|
||||
|
||||
expect(lunarEvents.length).toBeGreaterThan(0);
|
||||
console.log(`Found ${lunarEvents.length} lunar calendar events`);
|
||||
});
|
||||
|
||||
test("should handle all-day events correctly", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
const allDayEvents = result.events.filter((event) => event.allDay);
|
||||
const timedEvents = result.events.filter((event) => !event.allDay);
|
||||
|
||||
console.log(
|
||||
`All-day events: ${allDayEvents.length}, Timed events: ${timedEvents.length}`
|
||||
);
|
||||
|
||||
// Most Chinese lunar calendar events should be all-day
|
||||
expect(allDayEvents.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Parsing", () => {
|
||||
test("should parse dates correctly", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
|
||||
result.events.slice(0, 10).forEach((event) => {
|
||||
expect(event.dtstart).toBeInstanceOf(Date);
|
||||
expect(event.dtstart.getTime()).not.toBeNaN();
|
||||
|
||||
if (event.dtend) {
|
||||
expect(event.dtend).toBeInstanceOf(Date);
|
||||
expect(event.dtend.getTime()).not.toBeNaN();
|
||||
expect(event.dtend.getTime()).toBeGreaterThanOrEqual(
|
||||
event.dtstart.getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle current year events", () => {
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const currentYearEvents = result.events.filter(
|
||||
(event) => event.dtstart.getFullYear() === currentYear
|
||||
);
|
||||
|
||||
expect(currentYearEvents.length).toBeGreaterThan(0);
|
||||
console.log(
|
||||
`Found ${currentYearEvents.length} events for ${currentYear}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("should handle malformed ICS content gracefully", () => {
|
||||
const malformedContent = `
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:Test
|
||||
BEGIN:VEVENT
|
||||
UID:test-1
|
||||
SUMMARY:Test Event
|
||||
INVALID_PROPERTY_WITHOUT_COLON
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
`;
|
||||
|
||||
const result = IcsParser.parse(malformedContent, testSource);
|
||||
|
||||
expect(result.events).toBeDefined();
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should handle empty content", () => {
|
||||
const result = IcsParser.parse("", testSource);
|
||||
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle non-ICS content", () => {
|
||||
const result = IcsParser.parse(
|
||||
"This is not ICS content",
|
||||
testSource
|
||||
);
|
||||
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance Tests", () => {
|
||||
test("should parse large ICS content efficiently", () => {
|
||||
const startTime = performance.now();
|
||||
const result = IcsParser.parse(icsContent, testSource);
|
||||
const endTime = performance.now();
|
||||
|
||||
const parseTime = endTime - startTime;
|
||||
console.log(
|
||||
`Parsing took ${parseTime.toFixed(2)}ms for ${
|
||||
result.events.length
|
||||
} events`
|
||||
);
|
||||
|
||||
// Should parse within reasonable time (adjust threshold as needed)
|
||||
expect(parseTime).toBeLessThan(5000); // 5 seconds max
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock ICS content for testing when network is unavailable
|
||||
*/
|
||||
function getMockIcsContent(): string {
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Chinese Lunar Calendar//EN
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:中国农历
|
||||
X-WR-CALDESC:中国传统农历节日
|
||||
BEGIN:VEVENT
|
||||
UID:spring-festival-2024
|
||||
DTSTART;VALUE=DATE:20240210
|
||||
SUMMARY:春节 (农历正月初一)
|
||||
DESCRIPTION:中国传统新年,农历正月初一
|
||||
CATEGORIES:节日,传统节日
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:lantern-festival-2024
|
||||
DTSTART;VALUE=DATE:20240224
|
||||
SUMMARY:元宵节 (农历正月十五)
|
||||
DESCRIPTION:农历正月十五,传统元宵节
|
||||
CATEGORIES:节日,传统节日
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:dragon-boat-festival-2024
|
||||
DTSTART;VALUE=DATE:20240610
|
||||
SUMMARY:端午节 (农历五月初五)
|
||||
DESCRIPTION:农历五月初五,纪念屈原
|
||||
CATEGORIES:节日,传统节日
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:mid-autumn-festival-2024
|
||||
DTSTART;VALUE=DATE:20240917
|
||||
SUMMARY:中秋节 (农历八月十五)
|
||||
DESCRIPTION:农历八月十五,团圆节
|
||||
CATEGORIES:节日,传统节日
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:double-ninth-festival-2024
|
||||
DTSTART;VALUE=DATE:20241011
|
||||
SUMMARY:重阳节 (农历九月初九)
|
||||
DESCRIPTION:农历九月初九,登高节
|
||||
CATEGORIES:节日,传统节日
|
||||
STATUS:CONFIRMED
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
}
|
||||
|
|
@ -117,9 +117,6 @@ export const createMockText = (content: string): Text => {
|
|||
length: 0,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find line at pos ${pos} in content: "${content}"`
|
||||
);
|
||||
}),
|
||||
sliceString: jest.fn((from: number, to: number) =>
|
||||
content.slice(from, to)
|
||||
|
|
@ -330,6 +327,16 @@ const createMockPlugin = (
|
|||
autoAddNextTask: false,
|
||||
calculateFullSpentTime: false,
|
||||
},
|
||||
// Add sorting defaults
|
||||
sortTasks: true,
|
||||
sortCriteria: [
|
||||
{ field: "completed", order: "asc" },
|
||||
{ field: "status", order: "asc" },
|
||||
{ field: "priority", order: "asc" },
|
||||
{ field: "dueDate", order: "asc" },
|
||||
],
|
||||
// Add metadata format default
|
||||
preferMetadataFormat: "tasks",
|
||||
};
|
||||
|
||||
// Deep merge provided settings with defaults
|
||||
|
|
@ -343,6 +350,7 @@ const createMockPlugin = (
|
|||
...settings.taskStatusMarks,
|
||||
},
|
||||
workflow: { ...defaults.workflow, ...settings.workflow },
|
||||
sortCriteria: settings.sortCriteria || defaults.sortCriteria,
|
||||
};
|
||||
|
||||
// Return the plugin with app property
|
||||
|
|
|
|||
643
src/__tests__/projectUtils.test.ts
Normal file
643
src/__tests__/projectUtils.test.ts
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
/**
|
||||
* Project Utilities Tests
|
||||
*
|
||||
* Tests for project-related utility functions
|
||||
*/
|
||||
|
||||
import {
|
||||
getEffectiveProject,
|
||||
isProjectReadonly,
|
||||
hasProject,
|
||||
} from "../utils/taskUtil";
|
||||
import { Task } from "../types/task";
|
||||
import { TgProject } from "../types/task";
|
||||
|
||||
describe("Project Utility Functions", () => {
|
||||
describe("getEffectiveProject", () => {
|
||||
test("should return original project when available", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "Original Project",
|
||||
tgProject: {
|
||||
type: "path",
|
||||
name: "Path Project",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveProject(task);
|
||||
expect(result).toBe("Original Project");
|
||||
});
|
||||
|
||||
test("should return tgProject name when no original project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "metadata",
|
||||
name: "Metadata Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveProject(task);
|
||||
expect(result).toBe("Metadata Project");
|
||||
});
|
||||
|
||||
test("should return undefined when no project available", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveProject(task);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle empty string project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "",
|
||||
tgProject: {
|
||||
type: "path",
|
||||
name: "Fallback Project",
|
||||
source: "Projects",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveProject(task);
|
||||
expect(result).toBe("Fallback Project");
|
||||
});
|
||||
|
||||
test("should handle whitespace-only project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: " ",
|
||||
tgProject: {
|
||||
type: "config",
|
||||
name: "Config Project",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEffectiveProject(task);
|
||||
expect(result).toBe("Config Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProjectReadonly", () => {
|
||||
test("should return false for original project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "Original Project",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = isProjectReadonly(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for tgProject", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "path",
|
||||
name: "Path Project",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = isProjectReadonly(task);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when no project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = isProjectReadonly(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when original project exists even with tgProject", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "Original Project",
|
||||
tgProject: {
|
||||
type: "metadata",
|
||||
name: "Metadata Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = isProjectReadonly(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle tgProject with readonly false", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "metadata",
|
||||
name: "Custom Project",
|
||||
source: "manual",
|
||||
readonly: false,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = isProjectReadonly(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProject", () => {
|
||||
test("should return true when original project exists", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "Original Project",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true when tgProject exists", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "path",
|
||||
name: "Path Project",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when no project exists", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for empty string project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for whitespace-only project", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: " ",
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true when both projects exist", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
project: "Original Project",
|
||||
tgProject: {
|
||||
type: "metadata",
|
||||
name: "Metadata Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle tgProject with empty name", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "path",
|
||||
name: "",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle tgProject with whitespace-only name", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
type: "config",
|
||||
name: " ",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
},
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasProject(task);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle undefined metadata", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: undefined as any,
|
||||
};
|
||||
|
||||
expect(() => getEffectiveProject(task)).not.toThrow();
|
||||
expect(() => isProjectReadonly(task)).not.toThrow();
|
||||
expect(() => hasProject(task)).not.toThrow();
|
||||
|
||||
expect(getEffectiveProject(task)).toBeUndefined();
|
||||
expect(isProjectReadonly(task)).toBe(false);
|
||||
expect(hasProject(task)).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle null metadata", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: null as any,
|
||||
};
|
||||
|
||||
expect(() => getEffectiveProject(task)).not.toThrow();
|
||||
expect(() => isProjectReadonly(task)).not.toThrow();
|
||||
expect(() => hasProject(task)).not.toThrow();
|
||||
|
||||
expect(getEffectiveProject(task)).toBeUndefined();
|
||||
expect(isProjectReadonly(task)).toBe(false);
|
||||
expect(hasProject(task)).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle malformed tgProject", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: {
|
||||
// Missing required fields
|
||||
} as any,
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => getEffectiveProject(task)).not.toThrow();
|
||||
expect(() => isProjectReadonly(task)).not.toThrow();
|
||||
expect(() => hasProject(task)).not.toThrow();
|
||||
|
||||
expect(getEffectiveProject(task)).toBeUndefined();
|
||||
expect(isProjectReadonly(task)).toBe(false);
|
||||
expect(hasProject(task)).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle tgProject as non-object", () => {
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject: "invalid" as any,
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => getEffectiveProject(task)).not.toThrow();
|
||||
expect(() => isProjectReadonly(task)).not.toThrow();
|
||||
expect(() => hasProject(task)).not.toThrow();
|
||||
|
||||
expect(getEffectiveProject(task)).toBeUndefined();
|
||||
expect(isProjectReadonly(task)).toBe(false);
|
||||
expect(hasProject(task)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgProject Types", () => {
|
||||
test("should handle path type tgProject", () => {
|
||||
const tgProject: TgProject = {
|
||||
type: "path",
|
||||
name: "Path Project",
|
||||
source: "Projects/Work",
|
||||
readonly: true,
|
||||
};
|
||||
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject,
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getEffectiveProject(task)).toBe("Path Project");
|
||||
expect(isProjectReadonly(task)).toBe(true);
|
||||
expect(hasProject(task)).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle metadata type tgProject", () => {
|
||||
const tgProject: TgProject = {
|
||||
type: "metadata",
|
||||
name: "Metadata Project",
|
||||
source: "project",
|
||||
readonly: true,
|
||||
};
|
||||
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject,
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getEffectiveProject(task)).toBe("Metadata Project");
|
||||
expect(isProjectReadonly(task)).toBe(true);
|
||||
expect(hasProject(task)).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle config type tgProject", () => {
|
||||
const tgProject: TgProject = {
|
||||
type: "config",
|
||||
name: "Config Project",
|
||||
source: "project.md",
|
||||
readonly: true,
|
||||
};
|
||||
|
||||
const task: Task = {
|
||||
id: "test-1",
|
||||
content: "Test task",
|
||||
filePath: "test.md",
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: "- [ ] Test task",
|
||||
metadata: {
|
||||
tgProject,
|
||||
tags: [],
|
||||
children: [],
|
||||
heading: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(getEffectiveProject(task)).toBe("Config Project");
|
||||
expect(isProjectReadonly(task)).toBe(true);
|
||||
expect(hasProject(task)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -237,6 +237,13 @@ Just regular text content`;
|
|||
],
|
||||
});
|
||||
|
||||
// Debug: Test parseTaskLine directly
|
||||
const { parseTaskLine } = require("../utils/taskUtil");
|
||||
const testLine = "- [ ] Task A 📅 2025-01-01";
|
||||
const parsedTask = parseTaskLine("test.md", testLine, 1, "tasks");
|
||||
console.log("Parsed task:", parsedTask);
|
||||
console.log("Due date:", parsedTask?.metadata?.dueDate);
|
||||
|
||||
// Call sort function
|
||||
const result = sortTasksInDocument(mockView, mockPlugin, true);
|
||||
|
||||
|
|
|
|||
826
src/__tests__/taskParser.test.ts
Normal file
826
src/__tests__/taskParser.test.ts
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
/**
|
||||
* Task Parser Tests
|
||||
*
|
||||
* Tests for ConfigurableTaskParser and enhanced project functionality
|
||||
*/
|
||||
|
||||
import { MarkdownTaskParser } from "../utils/workers/ConfigurableTaskParser";
|
||||
import { getConfig } from "../common/task-parser-config";
|
||||
import { Task } from "../types/task";
|
||||
import { createMockPlugin } from "./mockUtils";
|
||||
import { MetadataParseMode } from "../types/TaskParserConfig";
|
||||
|
||||
// Mock file system for testing project.md functionality
|
||||
interface MockFile {
|
||||
path: string;
|
||||
content: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface MockVault {
|
||||
files: Map<string, MockFile>;
|
||||
addFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
metadata?: Record<string, any>
|
||||
) => void;
|
||||
getFile: (path: string) => MockFile | undefined;
|
||||
fileExists: (path: string) => boolean;
|
||||
}
|
||||
|
||||
const createMockVault = (): MockVault => {
|
||||
const files = new Map<string, MockFile>();
|
||||
|
||||
return {
|
||||
files,
|
||||
addFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
files.set(path, { path, content, metadata });
|
||||
},
|
||||
getFile: (path: string) => files.get(path),
|
||||
fileExists: (path: string) => files.has(path),
|
||||
};
|
||||
};
|
||||
|
||||
describe("ConfigurableTaskParser", () => {
|
||||
let parser: MarkdownTaskParser;
|
||||
let mockPlugin: any;
|
||||
let mockVault: MockVault;
|
||||
|
||||
beforeEach(() => {
|
||||
mockVault = createMockVault();
|
||||
|
||||
mockPlugin = createMockPlugin({
|
||||
preferMetadataFormat: "tasks",
|
||||
projectTagPrefix: {
|
||||
tasks: "project",
|
||||
dataview: "project",
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: "@",
|
||||
dataview: "context",
|
||||
},
|
||||
areaTagPrefix: {
|
||||
tasks: "area",
|
||||
dataview: "area",
|
||||
},
|
||||
projectConfig: {
|
||||
enableEnhancedProject: true,
|
||||
pathMappings: [
|
||||
{
|
||||
pathPattern: "Projects/Work",
|
||||
projectName: "Work Project",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
pathPattern: "Personal",
|
||||
projectName: "Personal Tasks",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
inheritFromFrontmatter: true,
|
||||
enabled: true,
|
||||
inheritFromFrontmatterForSubtasks: true,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "project.md",
|
||||
searchRecursively: true,
|
||||
enabled: true,
|
||||
},
|
||||
// Add missing required properties
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = getConfig("tasks", mockPlugin);
|
||||
parser = new MarkdownTaskParser(config);
|
||||
});
|
||||
|
||||
describe("Basic Task Parsing", () => {
|
||||
test("should parse simple task", () => {
|
||||
const content = "- [ ] Simple task";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Simple task");
|
||||
expect(tasks[0].completed).toBe(false);
|
||||
expect(tasks[0].status).toBe(" ");
|
||||
});
|
||||
|
||||
test("should parse completed task", () => {
|
||||
const content = "- [x] Completed task";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Completed task");
|
||||
expect(tasks[0].completed).toBe(true);
|
||||
expect(tasks[0].status).toBe("x");
|
||||
});
|
||||
|
||||
test("should parse task with different status", () => {
|
||||
const content = "- [/] In progress task";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("In progress task");
|
||||
expect(tasks[0].completed).toBe(false);
|
||||
expect(tasks[0].status).toBe("/");
|
||||
});
|
||||
|
||||
test("should parse multiple tasks", () => {
|
||||
const content = `- [ ] Task 1
|
||||
- [x] Task 2
|
||||
- [/] Task 3`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks[0].content).toBe("Task 1");
|
||||
expect(tasks[1].content).toBe("Task 2");
|
||||
expect(tasks[2].content).toBe("Task 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Parsing", () => {
|
||||
test("should parse task with project tag", () => {
|
||||
const content = "- [ ] Task with project #project/myproject";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.project).toBe("myproject");
|
||||
expect(tasks[0].content).toBe("Task with project");
|
||||
});
|
||||
|
||||
test("should parse task with dataview project format", () => {
|
||||
const content = "- [ ] Task with project [project:: myproject]";
|
||||
const config = getConfig("dataview", mockPlugin);
|
||||
const dataviewParser = new MarkdownTaskParser(config);
|
||||
const tasks = dataviewParser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.project).toBe("myproject");
|
||||
expect(tasks[0].content).toBe("Task with project");
|
||||
});
|
||||
|
||||
test("should parse task with nested project", () => {
|
||||
const content =
|
||||
"- [ ] Task with nested project #project/work/frontend";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.project).toBe("work/frontend");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enhanced Project Features", () => {
|
||||
test("should detect project from path mapping", () => {
|
||||
const content = "- [ ] Task without explicit project";
|
||||
const fileMetadata = {};
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Work/feature.md",
|
||||
fileMetadata
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("path");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Work Project");
|
||||
expect(tasks[0].metadata.tgProject?.source).toBe("Projects/Work");
|
||||
expect(tasks[0].metadata.tgProject?.readonly).toBe(true);
|
||||
});
|
||||
|
||||
test("should detect project from file metadata", () => {
|
||||
const content = "- [ ] Task without explicit project";
|
||||
const fileMetadata = { project: "Metadata Project" };
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"some/path/file.md",
|
||||
fileMetadata
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("metadata");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Metadata Project");
|
||||
expect(tasks[0].metadata.tgProject?.source).toBe("project");
|
||||
expect(tasks[0].metadata.tgProject?.readonly).toBe(true);
|
||||
});
|
||||
|
||||
test("should detect project from config file (project.md)", () => {
|
||||
const content = "- [ ] Task without explicit project";
|
||||
|
||||
// Mock project config data as if it was read from project.md
|
||||
const projectConfigData = {
|
||||
project: "Config Project",
|
||||
description: "A project defined in project.md",
|
||||
};
|
||||
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/MyProject/tasks.md",
|
||||
{}, // no file metadata
|
||||
projectConfigData // project config data from project.md
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("config");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Config Project");
|
||||
expect(tasks[0].metadata.tgProject?.source).toBe("project.md");
|
||||
expect(tasks[0].metadata.tgProject?.readonly).toBe(true);
|
||||
});
|
||||
|
||||
test("should prioritize explicit project over tgProject", () => {
|
||||
const content =
|
||||
"- [ ] Task with explicit project #project/explicit";
|
||||
const fileMetadata = { project: "Metadata Project" };
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Work/feature.md",
|
||||
fileMetadata
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.project).toBe("explicit");
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined(); // Should still be detected
|
||||
});
|
||||
|
||||
test("should inherit metadata from file frontmatter when enabled", () => {
|
||||
const content = "- [ ] Task without metadata";
|
||||
const fileMetadata = {
|
||||
project: "Inherited Project",
|
||||
priority: 3,
|
||||
context: "work",
|
||||
};
|
||||
const tasks = parser.parseLegacy(content, "test.md", fileMetadata);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Inherited Project");
|
||||
// Note: The inheritance logic should be implemented in the parser
|
||||
// For now, we're just testing that tgProject is detected from metadata
|
||||
});
|
||||
|
||||
test("should not override task metadata with file metadata", () => {
|
||||
const content = "- [ ] Task with explicit context @home";
|
||||
const fileMetadata = {
|
||||
project: "File Project",
|
||||
context: "office", // This should not override the task's explicit context
|
||||
};
|
||||
const tasks = parser.parseLegacy(content, "test.md", fileMetadata);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Task's explicit context should take precedence
|
||||
expect(tasks[0].metadata.context).toBe("home");
|
||||
// But project should be inherited since task doesn't have it
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("File Project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project.md Configuration File Tests", () => {
|
||||
test("should simulate reading project.md with frontmatter", () => {
|
||||
// Simulate project.md content with frontmatter
|
||||
const projectMdContent = `---
|
||||
project: Research Project
|
||||
description: A research project
|
||||
priority: high
|
||||
---
|
||||
|
||||
# Research Project
|
||||
|
||||
This is a research project with specific configuration.
|
||||
`;
|
||||
|
||||
mockVault.addFile(
|
||||
"Projects/Research/project.md",
|
||||
projectMdContent,
|
||||
{
|
||||
project: "Research Project",
|
||||
description: "A research project",
|
||||
priority: "high",
|
||||
}
|
||||
);
|
||||
|
||||
const content = "- [ ] Research task";
|
||||
const projectConfigData = mockVault.getFile(
|
||||
"Projects/Research/project.md"
|
||||
)?.metadata;
|
||||
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Research/tasks.md",
|
||||
{}, // no file metadata
|
||||
projectConfigData
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("config");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Research Project");
|
||||
});
|
||||
|
||||
test("should simulate reading project.md with inline configuration", () => {
|
||||
// Simulate project.md content with inline project configuration
|
||||
const projectMdContent = `# Development Project
|
||||
|
||||
project: Development Work
|
||||
context: development
|
||||
area: coding
|
||||
|
||||
This project involves software development tasks.
|
||||
`;
|
||||
|
||||
// Simulate parsing the content to extract inline configuration
|
||||
const projectConfigData = {
|
||||
project: "Development Work",
|
||||
context: "development",
|
||||
area: "coding",
|
||||
};
|
||||
|
||||
mockVault.addFile("Projects/Dev/project.md", projectMdContent);
|
||||
|
||||
const content = "- [ ] Implement feature";
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Dev/feature.md",
|
||||
{}, // no file metadata
|
||||
projectConfigData
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("config");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Development Work");
|
||||
});
|
||||
|
||||
test("should handle project.md in parent directory (recursive search)", () => {
|
||||
// Simulate project.md in parent directory
|
||||
const projectConfigData = {
|
||||
project: "Parent Project",
|
||||
description: "Project configuration from parent directory",
|
||||
};
|
||||
|
||||
mockVault.addFile("Projects/project.md", "project: Parent Project");
|
||||
|
||||
const content = "- [ ] Nested task";
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/SubFolder/DeepFolder/task.md",
|
||||
{}, // no file metadata
|
||||
projectConfigData
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("config");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Parent Project");
|
||||
});
|
||||
|
||||
test("should handle missing project.md gracefully", () => {
|
||||
const content = "- [ ] Task without project config";
|
||||
|
||||
// No project.md file exists, no project config data provided
|
||||
const tasks = parser.parseLegacy(content, "SomeFolder/task.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Should not have tgProject since no config file was found
|
||||
expect(tasks[0].metadata.tgProject).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should prioritize path mapping over project.md", () => {
|
||||
const content = "- [ ] Task in mapped path";
|
||||
const projectConfigData = {
|
||||
project: "Config Project",
|
||||
};
|
||||
|
||||
// Even though project.md exists, path mapping should take priority
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Work/task.md", // This matches path mapping
|
||||
{}, // no file metadata
|
||||
projectConfigData
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("path");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Work Project");
|
||||
});
|
||||
|
||||
test("should prioritize file metadata over project.md", () => {
|
||||
const content = "- [ ] Task with file metadata";
|
||||
const fileMetadata = { project: "File Metadata Project" };
|
||||
const projectConfigData = { project: "Config Project" };
|
||||
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"SomeFolder/task.md",
|
||||
fileMetadata,
|
||||
projectConfigData
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
expect(tasks[0].metadata.tgProject?.type).toBe("metadata");
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe(
|
||||
"File Metadata Project"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Context and Area Parsing", () => {
|
||||
test("should parse task with context", () => {
|
||||
const content = "- [ ] Task with context @home";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.context).toBe("home");
|
||||
expect(tasks[0].content).toBe("Task with context");
|
||||
});
|
||||
|
||||
test("should parse task with area", () => {
|
||||
const content = "- [ ] Task with area #area/personal";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Area should be parsed as metadata
|
||||
expect(tasks[0].metadata.area).toBe("personal");
|
||||
expect(tasks[0].content).toBe("Task with area");
|
||||
});
|
||||
|
||||
test("should parse task with dataview context format", () => {
|
||||
const content = "- [ ] Task with context [context:: home]";
|
||||
const config = getConfig("dataview", mockPlugin);
|
||||
const dataviewParser = new MarkdownTaskParser(config);
|
||||
const tasks = dataviewParser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.context).toBe("home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Parsing", () => {
|
||||
test("should parse task with due date emoji", () => {
|
||||
const content = "- [ ] Task with due date 📅 2024-12-31";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Due date emoji parsing might not be implemented yet
|
||||
// expect(tasks[0].metadata.dueDate).toBeDefined();
|
||||
expect(tasks[0].content).toBe("Task with due date");
|
||||
});
|
||||
|
||||
test("should parse task with start date emoji", () => {
|
||||
const content = "- [ ] Task with start date 🛫 2024-01-01";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Start date should be parsed as timestamp
|
||||
expect(tasks[0].metadata.startDate).toBe(1704038400000);
|
||||
expect(tasks[0].content).toBe("Task with start date");
|
||||
});
|
||||
|
||||
test("should parse task with scheduled date emoji", () => {
|
||||
const content = "- [ ] Task with scheduled date ⏳ 2024-06-15";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Scheduled date should be parsed as timestamp
|
||||
expect(tasks[0].metadata.scheduledDate).toBe(1718380800000);
|
||||
expect(tasks[0].content).toBe("Task with scheduled date");
|
||||
});
|
||||
|
||||
test("should parse task with dataview date format", () => {
|
||||
const content = "- [ ] Task with due date [dueDate:: 2024-12-31]";
|
||||
const config = getConfig("dataview", mockPlugin);
|
||||
const dataviewParser = new MarkdownTaskParser(config);
|
||||
const tasks = dataviewParser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Task with due date");
|
||||
// Dataview format parsing implementation is still in progress
|
||||
// Just verify the task content is parsed correctly for now
|
||||
});
|
||||
});
|
||||
|
||||
describe("Priority Parsing", () => {
|
||||
test("should parse task with high priority", () => {
|
||||
const content = "- [ ] High priority task 🔺";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.priority).toBeDefined();
|
||||
});
|
||||
|
||||
test("should parse task with medium priority", () => {
|
||||
const content = "- [ ] Medium priority task 🔼";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.priority).toBeDefined();
|
||||
});
|
||||
|
||||
test("should parse task with low priority", () => {
|
||||
const content = "- [ ] Low priority task 🔽";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.priority).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tags Parsing", () => {
|
||||
test("should parse task with single tag", () => {
|
||||
const content = "- [ ] Task with tag #important";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tags).toContain("#important");
|
||||
expect(tasks[0].content).toBe("Task with tag");
|
||||
});
|
||||
|
||||
test("should parse task with multiple tags", () => {
|
||||
const content = "- [ ] Task with tags #important #urgent #work";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tags).toContain("#important");
|
||||
expect(tasks[0].metadata.tags).toContain("#urgent");
|
||||
expect(tasks[0].metadata.tags).toContain("#work");
|
||||
expect(tasks[0].content).toBe("Task with tags");
|
||||
});
|
||||
|
||||
test("should filter out project tags from general tags", () => {
|
||||
const content =
|
||||
"- [ ] Task with mixed tags #important #project/myproject #urgent";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.project).toBe("myproject");
|
||||
expect(tasks[0].metadata.tags).toContain("#important");
|
||||
expect(tasks[0].metadata.tags).toContain("#urgent");
|
||||
expect(tasks[0].metadata.tags).not.toContain("#project/myproject");
|
||||
expect(tasks[0].content).toBe("Task with mixed tags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recurrence Parsing", () => {
|
||||
test("should parse task with recurrence", () => {
|
||||
const content = "- [ ] Recurring task 🔁 every week";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.recurrence).toBe("every week");
|
||||
});
|
||||
|
||||
test("should parse task with dataview recurrence", () => {
|
||||
const content = "- [ ] Recurring task [recurrence:: every month]";
|
||||
const config = getConfig("dataview", mockPlugin);
|
||||
const dataviewParser = new MarkdownTaskParser(config);
|
||||
const tasks = dataviewParser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.recurrence).toBe("every month");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Task Parsing", () => {
|
||||
test("should parse task with all metadata types", () => {
|
||||
const content =
|
||||
"- [ ] Complex task #project/work @office 🔺 #important #urgent 🔁 every week";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Complex task");
|
||||
expect(tasks[0].metadata.project).toBe("work");
|
||||
expect(tasks[0].metadata.context).toBe("office");
|
||||
expect(tasks[0].metadata.priority).toBeDefined();
|
||||
expect(tasks[0].metadata.tags).toContain("#important");
|
||||
expect(tasks[0].metadata.tags).toContain("#urgent");
|
||||
expect(tasks[0].metadata.recurrence).toBe("every week");
|
||||
});
|
||||
|
||||
test("should parse hierarchical tasks", () => {
|
||||
const content = `- [ ] Parent task #project/main
|
||||
- [ ] Child task 1
|
||||
- [ ] Grandchild task
|
||||
- [ ] Child task 2`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(4);
|
||||
|
||||
// Check parent task
|
||||
expect(tasks[0].content).toBe("Parent task");
|
||||
expect(tasks[0].metadata.project).toBe("main");
|
||||
expect(tasks[0].metadata.children).toHaveLength(2);
|
||||
|
||||
// Check child tasks
|
||||
expect(tasks[1].content).toBe("Child task 1");
|
||||
expect(tasks[1].metadata.parent).toBe(tasks[0].id);
|
||||
expect(tasks[1].metadata.children).toHaveLength(1);
|
||||
|
||||
expect(tasks[2].content).toBe("Grandchild task");
|
||||
expect(tasks[2].metadata.parent).toBe(tasks[1].id);
|
||||
|
||||
expect(tasks[3].content).toBe("Child task 2");
|
||||
expect(tasks[3].metadata.parent).toBe(tasks[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("should handle empty content", () => {
|
||||
const content = "";
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should handle content without tasks", () => {
|
||||
const content = `# Heading
|
||||
This is some text without tasks.
|
||||
- Regular list item
|
||||
- Another list item`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should handle malformed tasks", () => {
|
||||
const content = `- [ Malformed task 1
|
||||
- [] Malformed task 2
|
||||
- [x Malformed task 3
|
||||
- [ ] Valid task`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
// Should only parse the valid task
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Valid task");
|
||||
});
|
||||
|
||||
test("should handle tasks in code blocks", () => {
|
||||
const content = `\`\`\`
|
||||
- [ ] Task in code block
|
||||
\`\`\`
|
||||
- [ ] Real task`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
// Should only parse the task outside the code block
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe("Real task");
|
||||
});
|
||||
|
||||
test("should handle very long task content", () => {
|
||||
const longContent = "Very ".repeat(100) + "long task content";
|
||||
const content = `- [ ] ${longContent}`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].content).toBe(longContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Mapping Edge Cases", () => {
|
||||
test("should handle multiple matching path patterns", () => {
|
||||
// Add overlapping path mapping
|
||||
mockPlugin.settings.projectConfig.pathMappings.push({
|
||||
pathPattern: "Projects",
|
||||
projectName: "General Projects",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const content = "- [ ] Task in nested path";
|
||||
const tasks = parser.parseLegacy(
|
||||
content,
|
||||
"Projects/Work/subfolder/file.md"
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tgProject).toBeDefined();
|
||||
// Should match the more specific pattern first
|
||||
expect(tasks[0].metadata.tgProject?.name).toBe("Work Project");
|
||||
});
|
||||
|
||||
test("should handle disabled path mappings", () => {
|
||||
mockPlugin.settings.projectConfig.pathMappings[0].enabled = false;
|
||||
|
||||
const content = "- [ ] Task in disabled path";
|
||||
const tasks = parser.parseLegacy(content, "Projects/Work/file.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Should not detect project from disabled mapping
|
||||
expect(tasks[0].metadata.tgProject).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle case-sensitive path matching", () => {
|
||||
const content = "- [ ] Task in case different path";
|
||||
const tasks = parser.parseLegacy(content, "projects/work/file.md"); // lowercase
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
// Should not match due to case difference
|
||||
expect(tasks[0].metadata.tgProject).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Task Parser Utility Functions", () => {
|
||||
test("should generate unique task IDs", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
const content = `- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [ ] Task 3`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(3);
|
||||
const ids = tasks.map((t) => t.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(3); // All IDs should be unique
|
||||
});
|
||||
|
||||
test("should maintain consistent task IDs for same content", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
const content = "- [ ] Same task";
|
||||
|
||||
const tasks1 = parser.parseLegacy(content, "test.md");
|
||||
const tasks2 = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks1[0].id).toBe(tasks2[0].id);
|
||||
});
|
||||
|
||||
test("should handle different line endings", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
|
||||
const contentLF = "- [ ] Task 1\n- [ ] Task 2";
|
||||
const contentCRLF = "- [ ] Task 1\r\n- [ ] Task 2";
|
||||
|
||||
const tasksLF = parser.parseLegacy(contentLF, "test.md");
|
||||
const tasksCRLF = parser.parseLegacy(contentCRLF, "test.md");
|
||||
|
||||
expect(tasksLF).toHaveLength(2);
|
||||
expect(tasksCRLF).toHaveLength(2);
|
||||
expect(tasksLF[0].content).toBe(tasksCRLF[0].content);
|
||||
expect(tasksLF[1].content).toBe(tasksCRLF[1].content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Limits", () => {
|
||||
test("should handle large number of tasks", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
|
||||
// Generate 100 tasks
|
||||
const tasks = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `- [ ] Task ${i + 1}`
|
||||
);
|
||||
const content = tasks.join("\n");
|
||||
|
||||
const parsedTasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(parsedTasks).toHaveLength(100);
|
||||
expect(parsedTasks[0].content).toBe("Task 1");
|
||||
expect(parsedTasks[99].content).toBe("Task 100");
|
||||
});
|
||||
|
||||
test("should handle deeply nested tasks", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
|
||||
// Generate deeply nested tasks
|
||||
let content = "- [ ] Root task\n";
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const indent = " ".repeat(i);
|
||||
content += `${indent}- [ ] Level ${i} task\n`;
|
||||
}
|
||||
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(11);
|
||||
expect(tasks[0].content).toBe("Root task");
|
||||
expect(tasks[10].content).toBe("Level 10 task");
|
||||
|
||||
// Check parent-child relationships
|
||||
expect(tasks[1].metadata.parent).toBe(tasks[0].id);
|
||||
expect(tasks[10].metadata.parent).toBe(tasks[9].id);
|
||||
});
|
||||
|
||||
test("should handle tasks with very long metadata", () => {
|
||||
const parser = new MarkdownTaskParser(getConfig("tasks"));
|
||||
const longTag = "#" + "a".repeat(50);
|
||||
const longProject = "#project/" + "b".repeat(50);
|
||||
|
||||
const content = `- [ ] Task with long metadata ${longTag} ${longProject}`;
|
||||
const tasks = parser.parseLegacy(content, "test.md");
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].metadata.tags).toContain(longTag);
|
||||
expect(tasks[0].metadata.project).toBe("b".repeat(50));
|
||||
expect(tasks[0].content).toBe("Task with long metadata");
|
||||
});
|
||||
});
|
||||
1094
src/__tests__/workflow.test.ts
Normal file
1094
src/__tests__/workflow.test.ts
Normal file
File diff suppressed because it is too large
Load diff
742
src/__tests__/workflowDecorator.test.ts
Normal file
742
src/__tests__/workflowDecorator.test.ts
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
/**
|
||||
* Workflow Decorator Tests
|
||||
*
|
||||
* Tests for workflow decorator functionality including:
|
||||
* - Stage indicator widgets
|
||||
* - Tooltip content generation
|
||||
* - Click handling for stage transitions
|
||||
* - Visual styling and behavior
|
||||
*/
|
||||
|
||||
import { workflowDecoratorExtension } from "../editor-ext/workflowDecorator";
|
||||
import { createMockPlugin, createMockApp } from "./mockUtils";
|
||||
import { WorkflowDefinition } from "../common/setting-definition";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
// Mock setTooltip function from Obsidian
|
||||
jest.mock("obsidian", () => ({
|
||||
...jest.requireActual("obsidian"),
|
||||
setTooltip: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("Workflow Decorator Extension", () => {
|
||||
let mockPlugin: any;
|
||||
let mockApp: any;
|
||||
let sampleWorkflow: WorkflowDefinition;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApp = createMockApp();
|
||||
mockPlugin = createMockPlugin({
|
||||
workflow: {
|
||||
enableWorkflow: true,
|
||||
autoRemoveLastStageMarker: true,
|
||||
autoAddTimestamp: true,
|
||||
timestampFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
removeTimestampOnTransition: true,
|
||||
calculateSpentTime: true,
|
||||
spentTimeFormat: "HH:mm:ss",
|
||||
calculateFullSpentTime: true,
|
||||
definitions: [],
|
||||
autoAddNextTask: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Sample workflow definition for testing
|
||||
sampleWorkflow = {
|
||||
id: "development",
|
||||
name: "Development Workflow",
|
||||
description: "A typical software development workflow",
|
||||
stages: [
|
||||
{
|
||||
id: "planning",
|
||||
name: "Planning",
|
||||
type: "linear",
|
||||
next: "development",
|
||||
},
|
||||
{
|
||||
id: "development",
|
||||
name: "Development",
|
||||
type: "cycle",
|
||||
subStages: [
|
||||
{ id: "coding", name: "Coding", next: "testing" },
|
||||
{ id: "testing", name: "Testing", next: "review" },
|
||||
{ id: "review", name: "Code Review", next: "coding" },
|
||||
],
|
||||
canProceedTo: ["deployment"],
|
||||
},
|
||||
{
|
||||
id: "deployment",
|
||||
name: "Deployment",
|
||||
type: "linear",
|
||||
next: "monitoring",
|
||||
},
|
||||
{
|
||||
id: "monitoring",
|
||||
name: "Monitoring",
|
||||
type: "terminal",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
version: "1.0.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
mockPlugin.settings.workflow.definitions = [sampleWorkflow];
|
||||
});
|
||||
|
||||
describe("Extension Registration", () => {
|
||||
test("should return empty array when workflow is disabled", () => {
|
||||
const mockPluginDisabled = createMockPlugin({
|
||||
workflow: {
|
||||
enableWorkflow: false,
|
||||
autoAddTimestamp: false,
|
||||
timestampFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
removeTimestampOnTransition: false,
|
||||
calculateSpentTime: false,
|
||||
spentTimeFormat: "HH:mm:ss",
|
||||
calculateFullSpentTime: false,
|
||||
definitions: [],
|
||||
autoAddNextTask: false,
|
||||
autoRemoveLastStageMarker: false,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = workflowDecoratorExtension(
|
||||
mockApp,
|
||||
mockPluginDisabled
|
||||
);
|
||||
expect(extension).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return extension when workflow is enabled", () => {
|
||||
const extension = workflowDecoratorExtension(mockApp, mockPlugin);
|
||||
expect(extension).toBeTruthy();
|
||||
expect(Array.isArray(extension)).toBe(false); // Should be a ViewPlugin
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowStageWidget", () => {
|
||||
// Since WorkflowStageWidget is not exported, we'll test it through the extension
|
||||
// by creating mock editor states and checking the decorations
|
||||
|
||||
test("should create stage indicator for workflow tag", () => {
|
||||
const docText = "- [ ] Task with workflow #workflow/development";
|
||||
|
||||
// Create a mock editor state
|
||||
const state = EditorState.create({
|
||||
doc: docText,
|
||||
extensions: [workflowDecoratorExtension(mockApp, mockPlugin)],
|
||||
});
|
||||
|
||||
// The extension should process the workflow tag
|
||||
expect(state).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should create stage indicator for stage marker", () => {
|
||||
const docText = "- [ ] Planning task [stage::planning]";
|
||||
|
||||
// Create a mock editor state
|
||||
const state = EditorState.create({
|
||||
doc: docText,
|
||||
extensions: [workflowDecoratorExtension(mockApp, mockPlugin)],
|
||||
});
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should create stage indicator for substage marker", () => {
|
||||
const docText = "- [ ] Coding task [stage::development.coding]";
|
||||
|
||||
// Create a mock editor state
|
||||
const state = EditorState.create({
|
||||
doc: docText,
|
||||
extensions: [workflowDecoratorExtension(mockApp, mockPlugin)],
|
||||
});
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stage Icon Generation", () => {
|
||||
// Test the logic for generating stage icons based on stage type
|
||||
test("should use correct icon for linear stage", () => {
|
||||
// This would be tested by checking the DOM element created by the widget
|
||||
// Since we can't easily test the DOM creation in this environment,
|
||||
// we'll focus on the logic that determines the icon
|
||||
const linearStage = sampleWorkflow.stages[0]; // planning
|
||||
expect(linearStage.type).toBe("linear");
|
||||
// Icon should be "→"
|
||||
});
|
||||
|
||||
test("should use correct icon for cycle stage", () => {
|
||||
const cycleStage = sampleWorkflow.stages[1]; // development
|
||||
expect(cycleStage.type).toBe("cycle");
|
||||
// Icon should be "↻"
|
||||
});
|
||||
|
||||
test("should use correct icon for terminal stage", () => {
|
||||
const terminalStage = sampleWorkflow.stages[3]; // monitoring
|
||||
expect(terminalStage.type).toBe("terminal");
|
||||
// Icon should be "✓"
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tooltip Content Generation", () => {
|
||||
test("should generate correct tooltip for main stage", () => {
|
||||
// Test the tooltip content generation logic
|
||||
const expectedContent = [
|
||||
"Workflow: Development Workflow",
|
||||
"Current stage: Planning",
|
||||
"Type: linear",
|
||||
"Next: Development",
|
||||
];
|
||||
|
||||
// This would be tested by checking the tooltip content
|
||||
// The actual implementation would need to be refactored to make this testable
|
||||
expect(expectedContent).toContain("Workflow: Development Workflow");
|
||||
});
|
||||
|
||||
test("should generate correct tooltip for substage", () => {
|
||||
const expectedContent = [
|
||||
"Workflow: Development Workflow",
|
||||
"Current stage: Development (Coding)",
|
||||
"Type: cycle",
|
||||
"Next: Testing",
|
||||
];
|
||||
|
||||
expect(expectedContent).toContain(
|
||||
"Current stage: Development (Coding)"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle missing workflow definition", () => {
|
||||
// Test when workflow definition is not found
|
||||
const expectedContent = "Workflow not found";
|
||||
expect(expectedContent).toBe("Workflow not found");
|
||||
});
|
||||
|
||||
test("should handle missing stage definition", () => {
|
||||
// Test when stage definition is not found
|
||||
const expectedContent = "Stage not found";
|
||||
expect(expectedContent).toBe("Stage not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Click Handling", () => {
|
||||
test("should handle click on stage indicator", () => {
|
||||
// Test the click handling logic
|
||||
// This would involve creating a mock click event and verifying the dispatch
|
||||
|
||||
// Mock the editor view dispatch method
|
||||
const mockDispatch = jest.fn();
|
||||
const mockView = {
|
||||
state: {
|
||||
doc: {
|
||||
lineAt: jest.fn().mockReturnValue({
|
||||
number: 1,
|
||||
text: "- [ ] Planning task [stage::planning]",
|
||||
from: 0,
|
||||
to: 40,
|
||||
}),
|
||||
},
|
||||
},
|
||||
dispatch: mockDispatch,
|
||||
};
|
||||
|
||||
// The click handler should call dispatch with appropriate changes
|
||||
// This test would need the actual widget implementation to be more testable
|
||||
expect(mockDispatch).toBeDefined();
|
||||
});
|
||||
|
||||
test("should create stage transition on click", () => {
|
||||
// Test that clicking creates the appropriate stage transition
|
||||
const mockChanges = [
|
||||
{
|
||||
from: 3,
|
||||
to: 4,
|
||||
insert: "x", // Mark current task as completed
|
||||
},
|
||||
{
|
||||
from: 40,
|
||||
to: 40,
|
||||
insert: "\n - [ ] Development [stage::development] 🛫 2024-01-01 12:00:00",
|
||||
},
|
||||
];
|
||||
|
||||
// Verify the changes structure
|
||||
expect(mockChanges).toHaveLength(2);
|
||||
expect(mockChanges[0].insert).toBe("x");
|
||||
expect(mockChanges[1].insert).toContain("Development");
|
||||
});
|
||||
|
||||
test("should handle terminal stage click", () => {
|
||||
// Test clicking on terminal stage (should not create new task)
|
||||
const terminalStageClick = {
|
||||
shouldCreateNewTask: false,
|
||||
shouldMarkComplete: true,
|
||||
};
|
||||
|
||||
expect(terminalStageClick.shouldCreateNewTask).toBe(false);
|
||||
expect(terminalStageClick.shouldMarkComplete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Decoration Filtering", () => {
|
||||
test("should not render in code blocks", () => {
|
||||
// Test that decorations are not rendered in code blocks
|
||||
const codeBlockText = "```\n- [ ] Task [stage::planning]\n```";
|
||||
|
||||
// The shouldRender method should return false for code blocks
|
||||
// This would be tested by checking the syntax tree node properties
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
test("should not render in frontmatter", () => {
|
||||
// Test that decorations are not rendered in frontmatter
|
||||
const frontmatterText =
|
||||
"---\ntitle: Test\n---\n- [ ] Task [stage::planning]";
|
||||
|
||||
// The shouldRender method should return false for frontmatter
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
test("should not render when cursor is in decoration area", () => {
|
||||
// Test that decorations are hidden when cursor overlaps
|
||||
const cursorOverlap = {
|
||||
decorationFrom: 10,
|
||||
decorationTo: 20,
|
||||
cursorFrom: 15,
|
||||
cursorTo: 15,
|
||||
};
|
||||
|
||||
// Should return false when cursor overlaps (cursor is inside decoration area)
|
||||
const overlap = !(
|
||||
cursorOverlap.cursorTo <= cursorOverlap.decorationFrom ||
|
||||
cursorOverlap.cursorFrom >= cursorOverlap.decorationTo
|
||||
);
|
||||
const shouldRender = !overlap;
|
||||
expect(shouldRender).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Updates", () => {
|
||||
test("should throttle updates", () => {
|
||||
// Test that updates are throttled to avoid excessive re-rendering
|
||||
const updateThreshold = 50; // milliseconds
|
||||
const now = Date.now();
|
||||
const lastUpdate = now - 30; // Less than threshold
|
||||
|
||||
const shouldUpdate = now - lastUpdate >= updateThreshold;
|
||||
expect(shouldUpdate).toBe(false);
|
||||
});
|
||||
|
||||
test("should update on document changes", () => {
|
||||
// Test that decorations update when document changes
|
||||
const updateTriggers = {
|
||||
docChanged: true,
|
||||
selectionSet: false,
|
||||
viewportChanged: false,
|
||||
};
|
||||
|
||||
const shouldUpdate =
|
||||
updateTriggers.docChanged ||
|
||||
updateTriggers.selectionSet ||
|
||||
updateTriggers.viewportChanged;
|
||||
expect(shouldUpdate).toBe(true);
|
||||
});
|
||||
|
||||
test("should update on selection changes", () => {
|
||||
// Test that decorations update when selection changes
|
||||
const updateTriggers = {
|
||||
docChanged: false,
|
||||
selectionSet: true,
|
||||
viewportChanged: false,
|
||||
};
|
||||
|
||||
const shouldUpdate =
|
||||
updateTriggers.docChanged ||
|
||||
updateTriggers.selectionSet ||
|
||||
updateTriggers.viewportChanged;
|
||||
expect(shouldUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("should handle invalid workflow references gracefully", () => {
|
||||
// Test handling of invalid workflow references
|
||||
const invalidWorkflowTask = "- [ ] Task [stage::nonexistent.stage]";
|
||||
|
||||
// Should not crash and should show appropriate error indicator
|
||||
expect(invalidWorkflowTask).toContain("nonexistent");
|
||||
});
|
||||
|
||||
test("should handle malformed stage markers", () => {
|
||||
// Test handling of malformed stage markers
|
||||
const malformedMarkers = [
|
||||
"- [ ] Task [stage::]",
|
||||
"- [ ] Task [stage::.]",
|
||||
"- [ ] Task [stage::stage.]",
|
||||
];
|
||||
|
||||
malformedMarkers.forEach((marker) => {
|
||||
// Should not crash when processing malformed markers
|
||||
expect(marker).toContain("[stage::");
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle missing stage definitions", () => {
|
||||
// Test handling when stage is not found in workflow definition
|
||||
const missingStageTask = "- [ ] Task [stage::missing]";
|
||||
|
||||
// Should show "Stage not found" indicator
|
||||
expect(missingStageTask).toContain("missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration with Workflow System", () => {
|
||||
test("should integrate with workflow transaction handling", () => {
|
||||
// Test integration with the main workflow system
|
||||
const workflowIntegration = {
|
||||
decoratorExtension: true,
|
||||
workflowExtension: true,
|
||||
transactionHandling: true,
|
||||
};
|
||||
|
||||
expect(workflowIntegration.decoratorExtension).toBe(true);
|
||||
expect(workflowIntegration.workflowExtension).toBe(true);
|
||||
});
|
||||
|
||||
test("should respect workflow settings", () => {
|
||||
// Test that decorator respects workflow settings
|
||||
const settings = {
|
||||
autoAddTimestamp: true,
|
||||
autoRemoveLastStageMarker: true,
|
||||
calculateSpentTime: true,
|
||||
};
|
||||
|
||||
// Decorator should use these settings when creating transitions
|
||||
expect(settings.autoAddTimestamp).toBe(true);
|
||||
});
|
||||
|
||||
test("should work with different workflow types", () => {
|
||||
// Test compatibility with different workflow configurations
|
||||
const workflowTypes = ["linear", "cycle", "terminal"];
|
||||
|
||||
workflowTypes.forEach((type) => {
|
||||
expect(["linear", "cycle", "terminal"]).toContain(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility and UX", () => {
|
||||
test("should provide appropriate hover effects", () => {
|
||||
// Test that hover effects are applied correctly
|
||||
const hoverStyles = {
|
||||
backgroundColor: "var(--interactive-hover)",
|
||||
borderColor: "var(--interactive-accent)",
|
||||
};
|
||||
|
||||
expect(hoverStyles.backgroundColor).toBe(
|
||||
"var(--interactive-hover)"
|
||||
);
|
||||
});
|
||||
|
||||
test("should provide clear visual feedback", () => {
|
||||
// Test that visual feedback is clear and consistent
|
||||
const visualFeedback = {
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
borderRadius: "3px",
|
||||
};
|
||||
|
||||
expect(visualFeedback.cursor).toBe("pointer");
|
||||
});
|
||||
|
||||
test("should use appropriate colors for different stage types", () => {
|
||||
// Test color coding for different stage types
|
||||
const stageColors = {
|
||||
linear: "var(--text-accent)",
|
||||
cycle: "var(--task-in-progress-color)",
|
||||
terminal: "var(--task-completed-color)",
|
||||
};
|
||||
|
||||
expect(stageColors.linear).toBe("var(--text-accent)");
|
||||
expect(stageColors.cycle).toBe("var(--task-in-progress-color)");
|
||||
expect(stageColors.terminal).toBe("var(--task-completed-color)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Workflow Scenarios", () => {
|
||||
test("should handle stage jumping via decorator clicks", () => {
|
||||
// Test decorator behavior for stage jumping scenarios
|
||||
const stageJumpScenario = {
|
||||
currentStage: "development",
|
||||
currentSubStage: "coding",
|
||||
targetStage: "deployment",
|
||||
skipNormalFlow: true,
|
||||
};
|
||||
|
||||
// Should allow jumping to deployment stage via canProceedTo
|
||||
const developmentStage = sampleWorkflow.stages[1];
|
||||
expect(developmentStage.canProceedTo).toContain("deployment");
|
||||
expect(stageJumpScenario.skipNormalFlow).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle decorator with mixed plugin features", () => {
|
||||
// Test decorator with priority and status cycling
|
||||
const mixedFeatureTask = {
|
||||
text: "- [/] High priority task 🔺 [stage::development.coding]",
|
||||
hasWorkflowStage: true,
|
||||
hasPriorityMarker: true,
|
||||
hasInProgressStatus: true,
|
||||
};
|
||||
|
||||
expect(mixedFeatureTask.hasWorkflowStage).toBe(true);
|
||||
expect(mixedFeatureTask.hasPriorityMarker).toBe(true);
|
||||
expect(mixedFeatureTask.hasInProgressStatus).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle decorator in complex document structure", () => {
|
||||
// Test decorator with comments and metadata
|
||||
const complexStructure = {
|
||||
hasComments: true,
|
||||
hasMetadata: true,
|
||||
hasLinks: true,
|
||||
workflowStagePresent: true,
|
||||
};
|
||||
|
||||
expect(complexStructure.workflowStagePresent).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle decorator with different indentation levels", () => {
|
||||
const indentationLevels = [
|
||||
{ spaces: 2, valid: true },
|
||||
{ spaces: 4, valid: true },
|
||||
{ tabs: 1, valid: true },
|
||||
{ mixed: true, valid: true },
|
||||
];
|
||||
|
||||
indentationLevels.forEach((level) => {
|
||||
expect(level.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle decorator with time tracking elements", () => {
|
||||
const timeTrackingElements = {
|
||||
hasStartTimestamp: true,
|
||||
hasSpentTime: true,
|
||||
hasWorkflowStage: true,
|
||||
shouldRenderDecorator: true,
|
||||
};
|
||||
|
||||
expect(timeTrackingElements.shouldRenderDecorator).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle malformed stage markers", () => {
|
||||
const malformedCases = [
|
||||
{ marker: "[stage::]", shouldHandle: true },
|
||||
{ marker: "[stage::invalid..]", shouldHandle: true },
|
||||
{
|
||||
marker: "[stage::stage1.substage1.extra]",
|
||||
shouldHandle: true,
|
||||
},
|
||||
{ marker: "[stage::stage with spaces]", shouldHandle: true },
|
||||
];
|
||||
|
||||
malformedCases.forEach((testCase) => {
|
||||
expect(testCase.shouldHandle).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle decorator with missing workflow definition", () => {
|
||||
const missingWorkflowScenario = {
|
||||
workflowId: "nonexistent",
|
||||
shouldShowError: true,
|
||||
errorMessage: "Workflow not found",
|
||||
};
|
||||
|
||||
expect(missingWorkflowScenario.shouldShowError).toBe(true);
|
||||
expect(missingWorkflowScenario.errorMessage).toBe(
|
||||
"Workflow not found"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle decorator with missing stage definition", () => {
|
||||
const missingStageScenario = {
|
||||
stageId: "nonexistent",
|
||||
shouldShowError: true,
|
||||
errorMessage: "Stage not found",
|
||||
};
|
||||
|
||||
expect(missingStageScenario.shouldShowError).toBe(true);
|
||||
expect(missingStageScenario.errorMessage).toBe("Stage not found");
|
||||
});
|
||||
|
||||
test("should handle decorator click without active editor", () => {
|
||||
const noEditorScenario = {
|
||||
hasActiveEditor: false,
|
||||
shouldHandleGracefully: true,
|
||||
shouldPreventDefault: true,
|
||||
};
|
||||
|
||||
expect(noEditorScenario.shouldHandleGracefully).toBe(true);
|
||||
expect(noEditorScenario.shouldPreventDefault).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle decorator with very long stage names", () => {
|
||||
const longNameScenario = {
|
||||
stageName: "a".repeat(100),
|
||||
shouldRender: true,
|
||||
shouldTruncate: false,
|
||||
};
|
||||
|
||||
expect(longNameScenario.shouldRender).toBe(true);
|
||||
expect(longNameScenario.stageName.length).toBe(100);
|
||||
});
|
||||
|
||||
test("should handle decorator updates during rapid typing", () => {
|
||||
const rapidTypingScenario = {
|
||||
updateCount: 10,
|
||||
shouldThrottle: true,
|
||||
shouldNotCrash: true,
|
||||
};
|
||||
|
||||
expect(rapidTypingScenario.shouldThrottle).toBe(true);
|
||||
expect(rapidTypingScenario.shouldNotCrash).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration with Other Plugin Features", () => {
|
||||
test("should work with cycleStatus functionality", () => {
|
||||
const cycleStatusIntegration = {
|
||||
hasInProgressStatus: true,
|
||||
hasWorkflowStage: true,
|
||||
shouldRenderBoth: true,
|
||||
};
|
||||
|
||||
expect(cycleStatusIntegration.shouldRenderBoth).toBe(true);
|
||||
});
|
||||
|
||||
test("should work with autoComplete parent functionality", () => {
|
||||
const autoCompleteIntegration = {
|
||||
hasParentTask: true,
|
||||
hasWorkflowStage: true,
|
||||
shouldNotInterfere: true,
|
||||
};
|
||||
|
||||
expect(autoCompleteIntegration.shouldNotInterfere).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle mixed task statuses in workflow", () => {
|
||||
const mixedStatuses = [
|
||||
{ status: " ", description: "not started", shouldHandle: true },
|
||||
{ status: "/", description: "in progress", shouldHandle: true },
|
||||
{ status: "x", description: "completed", shouldHandle: true },
|
||||
{ status: "-", description: "abandoned", shouldHandle: true },
|
||||
{ status: "?", description: "planned", shouldHandle: true },
|
||||
];
|
||||
|
||||
mixedStatuses.forEach((statusTest) => {
|
||||
expect(statusTest.shouldHandle).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle workflow with priority markers", () => {
|
||||
const priorityIntegration = {
|
||||
hasPriorityMarker: true,
|
||||
hasWorkflowStage: true,
|
||||
shouldExtractBoth: true,
|
||||
};
|
||||
|
||||
expect(priorityIntegration.shouldExtractBoth).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Document Structure Handling", () => {
|
||||
test("should handle tasks separated by comments", () => {
|
||||
const commentSeparation = {
|
||||
hasCommentsBetween: true,
|
||||
shouldResolveWorkflow: true,
|
||||
shouldNotBreak: true,
|
||||
};
|
||||
|
||||
expect(commentSeparation.shouldResolveWorkflow).toBe(true);
|
||||
expect(commentSeparation.shouldNotBreak).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle tasks separated by multiple lines", () => {
|
||||
const lineSeparation = {
|
||||
hasBlankLines: true,
|
||||
shouldCalculateInsertionPoint: true,
|
||||
shouldNotBreak: true,
|
||||
};
|
||||
|
||||
expect(lineSeparation.shouldCalculateInsertionPoint).toBe(true);
|
||||
expect(lineSeparation.shouldNotBreak).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested task structures", () => {
|
||||
const nestedStructure = {
|
||||
hasNestedTasks: true,
|
||||
shouldResolveWorkflow: true,
|
||||
shouldNotBreakResolution: true,
|
||||
};
|
||||
|
||||
expect(nestedStructure.shouldResolveWorkflow).toBe(true);
|
||||
expect(nestedStructure.shouldNotBreakResolution).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle tasks with metadata and links", () => {
|
||||
const metadataHandling = {
|
||||
hasMetadata: true,
|
||||
hasLinks: true,
|
||||
shouldExtractWorkflow: true,
|
||||
shouldNotInterfere: true,
|
||||
};
|
||||
|
||||
expect(metadataHandling.shouldExtractWorkflow).toBe(true);
|
||||
expect(metadataHandling.shouldNotInterfere).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle workflow tasks in different list formats", () => {
|
||||
const listFormats = [
|
||||
{ marker: "-", shouldHandle: true },
|
||||
{ marker: "*", shouldHandle: true },
|
||||
{ marker: "+", shouldHandle: true },
|
||||
{ marker: "1.", shouldHandle: true },
|
||||
];
|
||||
|
||||
listFormats.forEach((format) => {
|
||||
expect(format.shouldHandle).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle workflow tasks with time tracking", () => {
|
||||
const timeTrackingHandling = {
|
||||
hasStartTime: true,
|
||||
hasSpentTime: true,
|
||||
shouldCalculateTime: true,
|
||||
shouldRenderDecorator: true,
|
||||
};
|
||||
|
||||
expect(timeTrackingHandling.shouldRenderDecorator).toBe(true);
|
||||
expect(timeTrackingHandling.shouldCalculateTime).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle workflow tasks with indentation variations", () => {
|
||||
const indentationHandling = {
|
||||
hasSpaces: true,
|
||||
hasTabs: true,
|
||||
hasMixed: true,
|
||||
shouldHandleAll: true,
|
||||
};
|
||||
|
||||
expect(indentationHandling.shouldHandleAll).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
495
src/__tests__/workflowOptimization.test.ts
Normal file
495
src/__tests__/workflowOptimization.test.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* Workflow Optimization Tests
|
||||
*
|
||||
* Tests for the new workflow optimization features including:
|
||||
* - Quick workflow creation
|
||||
* - Task to workflow conversion
|
||||
* - Workflow starting task creation
|
||||
* - Workflow progress indicators
|
||||
* - Enhanced user experience features
|
||||
*/
|
||||
|
||||
import {
|
||||
analyzeTaskStructure,
|
||||
convertTaskStructureToWorkflow,
|
||||
createWorkflowStartingTask,
|
||||
convertCurrentTaskToWorkflowRoot,
|
||||
suggestWorkflowFromExisting,
|
||||
} from "../utils/workflowConversion";
|
||||
import { WorkflowProgressIndicator } from "../components/WorkflowProgressIndicator";
|
||||
import { createMockPlugin, createMockApp } from "./mockUtils";
|
||||
import { WorkflowDefinition } from "../common/setting-definition";
|
||||
|
||||
// Mock Editor for testing
|
||||
class MockEditor {
|
||||
private lines: string[];
|
||||
private cursor: { line: number; ch: number };
|
||||
|
||||
constructor(content: string) {
|
||||
this.lines = content.split("\n");
|
||||
this.cursor = { line: 0, ch: 0 };
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.lines.join("\n");
|
||||
}
|
||||
|
||||
getLine(line: number): string {
|
||||
return this.lines[line] || "";
|
||||
}
|
||||
|
||||
getCursor() {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
setCursor(line: number, ch: number = 0) {
|
||||
this.cursor = { line, ch };
|
||||
}
|
||||
|
||||
setLine(line: number, text: string) {
|
||||
this.lines[line] = text;
|
||||
}
|
||||
|
||||
replaceRange(text: string, from: any, to: any) {
|
||||
// Simple implementation for testing
|
||||
const line = this.lines[from.line];
|
||||
const before = line.substring(0, from.ch);
|
||||
const after = line.substring(to.ch);
|
||||
const newContent = before + text + after;
|
||||
|
||||
// Handle newlines by splitting into multiple lines
|
||||
if (newContent.includes("\n")) {
|
||||
const parts = newContent.split("\n");
|
||||
this.lines[from.line] = parts[0];
|
||||
// Insert additional lines if needed
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
this.lines.splice(from.line + i, 0, parts[i]);
|
||||
}
|
||||
} else {
|
||||
this.lines[from.line] = newContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Workflow Optimization Features", () => {
|
||||
let mockPlugin: any;
|
||||
let mockApp: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlugin = createMockPlugin();
|
||||
mockApp = createMockApp();
|
||||
|
||||
// Add some sample workflows
|
||||
mockPlugin.settings.workflow.definitions = [
|
||||
{
|
||||
id: "simple_workflow",
|
||||
name: "Simple Workflow",
|
||||
description: "A basic workflow",
|
||||
stages: [
|
||||
{ id: "start", name: "Start", type: "linear" },
|
||||
{ id: "middle", name: "Middle", type: "linear" },
|
||||
{ id: "end", name: "End", type: "terminal" },
|
||||
],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
describe("Task Structure Analysis", () => {
|
||||
test("should analyze simple task structure", () => {
|
||||
const content = `- [ ] Main Task
|
||||
- [ ] Subtask 1
|
||||
- [ ] Subtask 2`;
|
||||
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const structure = analyzeTaskStructure(
|
||||
editor as any,
|
||||
editor.getCursor()
|
||||
);
|
||||
|
||||
expect(structure).toBeTruthy();
|
||||
expect(structure?.content).toBe("Main Task");
|
||||
expect(structure?.isTask).toBe(true);
|
||||
expect(structure?.children).toHaveLength(2);
|
||||
expect(structure?.children[0].content).toBe("Subtask 1");
|
||||
expect(structure?.children[1].content).toBe("Subtask 2");
|
||||
});
|
||||
|
||||
test("should handle nested task structure", () => {
|
||||
const content = `- [ ] Project
|
||||
- [ ] Phase 1
|
||||
- [ ] Task 1.1
|
||||
- [ ] Task 1.2
|
||||
- [ ] Phase 2
|
||||
- [ ] Task 2.1`;
|
||||
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const structure = analyzeTaskStructure(
|
||||
editor as any,
|
||||
editor.getCursor()
|
||||
);
|
||||
|
||||
expect(structure).toBeTruthy();
|
||||
expect(structure?.content).toBe("Project");
|
||||
expect(structure?.children).toHaveLength(2);
|
||||
expect(structure?.children[0].content).toBe("Phase 1");
|
||||
expect(structure?.children[0].children).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("should return null for non-task lines", () => {
|
||||
const content = `This is just text
|
||||
Not a task`;
|
||||
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const structure = analyzeTaskStructure(
|
||||
editor as any,
|
||||
editor.getCursor()
|
||||
);
|
||||
|
||||
expect(structure).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Task to Workflow Conversion", () => {
|
||||
test("should convert simple task structure to workflow", () => {
|
||||
const structure = {
|
||||
content: "Project Setup",
|
||||
level: 0,
|
||||
line: 0,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [
|
||||
{
|
||||
content: "Initialize Repository",
|
||||
level: 2,
|
||||
line: 1,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
content: "Setup Dependencies",
|
||||
level: 2,
|
||||
line: 2,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const workflow = convertTaskStructureToWorkflow(
|
||||
structure,
|
||||
"Project Setup Workflow",
|
||||
"project_setup"
|
||||
);
|
||||
|
||||
expect(workflow.id).toBe("project_setup");
|
||||
expect(workflow.name).toBe("Project Setup Workflow");
|
||||
expect(workflow.stages).toHaveLength(3); // Root + 2 children
|
||||
expect(workflow.stages[0].name).toBe("Project Setup");
|
||||
expect(workflow.stages[1].name).toBe("Initialize Repository");
|
||||
expect(workflow.stages[2].name).toBe("Setup Dependencies");
|
||||
});
|
||||
|
||||
test("should handle cycle stages with substages", () => {
|
||||
const structure = {
|
||||
content: "Development",
|
||||
level: 0,
|
||||
line: 0,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [
|
||||
{
|
||||
content: "Code",
|
||||
level: 2,
|
||||
line: 1,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [
|
||||
{
|
||||
content: "Write Tests",
|
||||
level: 4,
|
||||
line: 2,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
content: "Implement Feature",
|
||||
level: 4,
|
||||
line: 3,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const workflow = convertTaskStructureToWorkflow(
|
||||
structure,
|
||||
"Development Workflow",
|
||||
"development"
|
||||
);
|
||||
|
||||
expect(workflow.stages).toHaveLength(2);
|
||||
expect(workflow.stages[1].type).toBe("cycle");
|
||||
expect(workflow.stages[1].subStages).toHaveLength(2);
|
||||
expect(workflow.stages[1].subStages?.[0].name).toBe("Write Tests");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Starting Task Creation", () => {
|
||||
test("should create workflow starting task at cursor", () => {
|
||||
const content = `Some existing content
|
||||
`;
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(1);
|
||||
|
||||
const workflow: WorkflowDefinition = {
|
||||
id: "test_workflow",
|
||||
name: "Test Workflow",
|
||||
description: "Test",
|
||||
stages: [],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
createWorkflowStartingTask(
|
||||
editor as any,
|
||||
editor.getCursor(),
|
||||
workflow,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
expect(editor.getLine(1)).toBe(
|
||||
"- [ ] Test Workflow #workflow/test_workflow"
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle indentation correctly", () => {
|
||||
const content = ` Some indented content
|
||||
`;
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const workflow: WorkflowDefinition = {
|
||||
id: "test_workflow",
|
||||
name: "Test Workflow",
|
||||
description: "Test",
|
||||
stages: [],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
createWorkflowStartingTask(
|
||||
editor as any,
|
||||
editor.getCursor(),
|
||||
workflow,
|
||||
mockPlugin
|
||||
);
|
||||
|
||||
// The function adds a new line after the existing content
|
||||
expect(editor.getLine(0)).toBe(" Some indented content");
|
||||
expect(editor.getLine(1)).toBe(
|
||||
" - [ ] Test Workflow #workflow/test_workflow"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current Task to Workflow Root Conversion", () => {
|
||||
test("should convert task to workflow root", () => {
|
||||
const content = `- [ ] My Task`;
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const success = convertCurrentTaskToWorkflowRoot(
|
||||
editor as any,
|
||||
editor.getCursor(),
|
||||
"my_workflow"
|
||||
);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(editor.getLine(0)).toBe(
|
||||
"- [ ] My Task #workflow/my_workflow"
|
||||
);
|
||||
});
|
||||
|
||||
test("should not convert non-task lines", () => {
|
||||
const content = `Just some text`;
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const success = convertCurrentTaskToWorkflowRoot(
|
||||
editor as any,
|
||||
editor.getCursor(),
|
||||
"my_workflow"
|
||||
);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(editor.getLine(0)).toBe("Just some text");
|
||||
});
|
||||
|
||||
test("should not convert tasks that already have workflow tags", () => {
|
||||
const content = `- [ ] My Task #workflow/existing`;
|
||||
const editor = new MockEditor(content);
|
||||
editor.setCursor(0);
|
||||
|
||||
const success = convertCurrentTaskToWorkflowRoot(
|
||||
editor as any,
|
||||
editor.getCursor(),
|
||||
"my_workflow"
|
||||
);
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(editor.getLine(0)).toBe("- [ ] My Task #workflow/existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Suggestions", () => {
|
||||
test("should suggest similar workflow based on stage count", () => {
|
||||
const structure = {
|
||||
content: "New Project",
|
||||
level: 0,
|
||||
line: 0,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [
|
||||
{
|
||||
content: "Step 1",
|
||||
level: 2,
|
||||
line: 1,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
content: "Step 2",
|
||||
level: 2,
|
||||
line: 2,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const existingWorkflows = mockPlugin.settings.workflow.definitions;
|
||||
const suggestion = suggestWorkflowFromExisting(
|
||||
structure,
|
||||
existingWorkflows
|
||||
);
|
||||
|
||||
expect(suggestion).toBeTruthy();
|
||||
expect(suggestion?.name).toBe("New Project Workflow");
|
||||
expect(suggestion?.stages).toHaveLength(3); // Same as existing workflow
|
||||
});
|
||||
|
||||
test("should return null when no similar workflows exist", () => {
|
||||
const structure = {
|
||||
content: "Complex Project",
|
||||
level: 0,
|
||||
line: 0,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
content: `Step ${i + 1}`,
|
||||
level: 2,
|
||||
line: i + 1,
|
||||
isTask: true,
|
||||
status: " ",
|
||||
children: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const existingWorkflows = mockPlugin.settings.workflow.definitions;
|
||||
const suggestion = suggestWorkflowFromExisting(
|
||||
structure,
|
||||
existingWorkflows
|
||||
);
|
||||
|
||||
expect(suggestion).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Progress Indicator", () => {
|
||||
test("should calculate progress correctly", () => {
|
||||
const workflow: WorkflowDefinition = {
|
||||
id: "test_workflow",
|
||||
name: "Test Workflow",
|
||||
description: "Test",
|
||||
stages: [
|
||||
{ id: "stage1", name: "Stage 1", type: "linear" },
|
||||
{ id: "stage2", name: "Stage 2", type: "linear" },
|
||||
{ id: "stage3", name: "Stage 3", type: "terminal" },
|
||||
],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
const completedStages = ["stage1"];
|
||||
const currentStageId = "stage2";
|
||||
|
||||
// Test the static calculation method
|
||||
const workflowTasks = [
|
||||
{ stage: "stage1", completed: true },
|
||||
{ stage: "stage1", completed: true },
|
||||
{ stage: "stage2", completed: false },
|
||||
{ stage: "stage3", completed: false },
|
||||
];
|
||||
|
||||
const calculated =
|
||||
WorkflowProgressIndicator.calculateCompletedStages(
|
||||
workflowTasks,
|
||||
workflow
|
||||
);
|
||||
|
||||
expect(calculated).toContain("stage1");
|
||||
expect(calculated).not.toContain("stage2");
|
||||
});
|
||||
|
||||
test("should handle empty workflow tasks", () => {
|
||||
const workflow: WorkflowDefinition = {
|
||||
id: "test_workflow",
|
||||
name: "Test Workflow",
|
||||
description: "Test",
|
||||
stages: [{ id: "stage1", name: "Stage 1", type: "linear" }],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: "2024-01-01",
|
||||
lastModified: "2024-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
const calculated =
|
||||
WorkflowProgressIndicator.calculateCompletedStages(
|
||||
[],
|
||||
workflow
|
||||
);
|
||||
expect(calculated).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1473,3 +1473,216 @@ export function moveIncompletedTasksCommand(
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-move completed tasks using default settings
|
||||
*/
|
||||
export async function autoMoveCompletedTasks(
|
||||
editor: Editor,
|
||||
currentFile: TFile,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
taskLines: number[],
|
||||
moveMode:
|
||||
| "allCompleted"
|
||||
| "directChildren"
|
||||
| "all"
|
||||
| "allIncompleted"
|
||||
| "directIncompletedChildren"
|
||||
): Promise<boolean> {
|
||||
const settings = plugin.settings.completedTaskMover;
|
||||
|
||||
// Check if auto-move is enabled and default file is set
|
||||
const isCompletedMode = moveMode === "allCompleted" || moveMode === "directChildren" || moveMode === "all";
|
||||
const isAutoMoveEnabled = isCompletedMode ? settings.enableAutoMove : settings.enableIncompletedAutoMove;
|
||||
const defaultTargetFile = isCompletedMode ? settings.defaultTargetFile : settings.incompletedDefaultTargetFile;
|
||||
const defaultInsertionMode = isCompletedMode ? settings.defaultInsertionMode : settings.incompletedDefaultInsertionMode;
|
||||
const defaultHeadingName = isCompletedMode ? settings.defaultHeadingName : settings.incompletedDefaultHeadingName;
|
||||
|
||||
if (!isAutoMoveEnabled || !defaultTargetFile) {
|
||||
return false; // Auto-move not configured, fall back to manual selection
|
||||
}
|
||||
|
||||
try {
|
||||
// Get tasks content
|
||||
const { content, linesToRemove } = TaskUtils.processSelectedTasks(
|
||||
editor,
|
||||
taskLines,
|
||||
moveMode,
|
||||
plugin.settings,
|
||||
currentFile,
|
||||
plugin.app
|
||||
);
|
||||
|
||||
// Find or create target file
|
||||
let targetFile = plugin.app.vault.getAbstractFileByPath(defaultTargetFile);
|
||||
|
||||
if (!targetFile) {
|
||||
// Create the file if it doesn't exist
|
||||
targetFile = await plugin.app.vault.create(defaultTargetFile, "");
|
||||
}
|
||||
|
||||
if (!(targetFile instanceof TFile)) {
|
||||
throw new Error(`Target path ${defaultTargetFile} is not a file`);
|
||||
}
|
||||
|
||||
// Read target file content
|
||||
const fileContent = await plugin.app.vault.read(targetFile);
|
||||
const lines = fileContent.split("\n");
|
||||
|
||||
let insertPosition: number;
|
||||
let indentLevel: number = 0;
|
||||
|
||||
// Determine insertion position based on mode
|
||||
switch (defaultInsertionMode) {
|
||||
case "beginning":
|
||||
insertPosition = 0;
|
||||
break;
|
||||
case "end":
|
||||
insertPosition = lines.length;
|
||||
break;
|
||||
case "after-heading":
|
||||
// Find the heading or create it
|
||||
const headingPattern = new RegExp(`^#+\\s+${defaultHeadingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i');
|
||||
let headingLineIndex = lines.findIndex(line => headingPattern.test(line));
|
||||
|
||||
if (headingLineIndex === -1) {
|
||||
// Create the heading at the end of the file
|
||||
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
|
||||
lines.push(""); // Add empty line before heading
|
||||
}
|
||||
lines.push(`## ${defaultHeadingName}`);
|
||||
lines.push(""); // Add empty line after heading
|
||||
headingLineIndex = lines.length - 2; // Index of the heading line
|
||||
}
|
||||
|
||||
insertPosition = headingLineIndex + 1;
|
||||
// Skip any empty lines after the heading
|
||||
while (insertPosition < lines.length && lines[insertPosition].trim() === "") {
|
||||
insertPosition++;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
insertPosition = lines.length;
|
||||
}
|
||||
|
||||
// Adjust indentation of task content
|
||||
const indentedTaskContent = TaskUtils.adjustIndentation(
|
||||
content,
|
||||
indentLevel,
|
||||
plugin.app
|
||||
);
|
||||
|
||||
// Insert task at the position
|
||||
const newContent = [
|
||||
...lines.slice(0, insertPosition),
|
||||
indentedTaskContent,
|
||||
...lines.slice(insertPosition),
|
||||
].join("\n");
|
||||
|
||||
// Update target file
|
||||
await plugin.app.vault.modify(targetFile, newContent);
|
||||
|
||||
// Remove tasks from source file
|
||||
TaskUtils.removeTasksFromFile(editor, linesToRemove);
|
||||
|
||||
const taskType = isCompletedMode ? "completed" : "incomplete";
|
||||
new Notice(`${t("Auto-moved")} ${taskType} ${t("tasks to")} ${defaultTargetFile}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
new Notice(`${t("Failed to auto-move tasks:")} ${error}`);
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to auto-move completed tasks using default settings
|
||||
*/
|
||||
export function autoMoveCompletedTasksCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
moveMode:
|
||||
| "allCompleted"
|
||||
| "directChildren"
|
||||
| "all"
|
||||
| "allIncompleted"
|
||||
| "directIncompletedChildren"
|
||||
): boolean {
|
||||
// Get the current file
|
||||
const currentFile = ctx.file;
|
||||
|
||||
if (checking) {
|
||||
// Check if auto-move is enabled for this mode
|
||||
const isCompletedMode = moveMode === "allCompleted" || moveMode === "directChildren" || moveMode === "all";
|
||||
const isAutoMoveEnabled = isCompletedMode
|
||||
? plugin.settings.completedTaskMover.enableAutoMove
|
||||
: plugin.settings.completedTaskMover.enableIncompletedAutoMove;
|
||||
const defaultTargetFile = isCompletedMode
|
||||
? plugin.settings.completedTaskMover.defaultTargetFile
|
||||
: plugin.settings.completedTaskMover.incompletedDefaultTargetFile;
|
||||
|
||||
if (!isAutoMoveEnabled || !defaultTargetFile) {
|
||||
return false; // Auto-move not configured
|
||||
}
|
||||
|
||||
// If checking, return true if we're in a markdown file and cursor is on a task line
|
||||
if (!currentFile || currentFile.extension !== "md") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = editor.getSelection();
|
||||
if (selection.length === 0) {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
// Check if line is a task with any of the supported list markers (-, 1., *)
|
||||
return line.match(/^\s*(-|\d+\.|\*) \[(.)\]/i) !== null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
if (!currentFile) {
|
||||
new Notice(t("No active file found"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get all selections to support multi-line selection
|
||||
const selections = editor.listSelections();
|
||||
|
||||
// Extract all selected lines from the selections
|
||||
const selectedLinesSet = new Set<number>();
|
||||
selections.forEach((selection) => {
|
||||
// Get the start and end lines (accounting for selection direction)
|
||||
const startLine = Math.min(selection.anchor.line, selection.head.line);
|
||||
const endLine = Math.max(selection.anchor.line, selection.head.line);
|
||||
|
||||
// Add all lines in this selection range
|
||||
for (let line = startLine; line <= endLine; line++) {
|
||||
selectedLinesSet.add(line);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert Set to Array for further processing
|
||||
const selectedLines = Array.from(selectedLinesSet);
|
||||
|
||||
// Try auto-move first, fall back to manual selection if it fails
|
||||
autoMoveCompletedTasks(editor, currentFile, plugin, selectedLines, moveMode)
|
||||
.then((success) => {
|
||||
if (!success) {
|
||||
// Fall back to manual selection
|
||||
new CompletedTaskFileSelectionModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
editor,
|
||||
currentFile,
|
||||
selectedLines,
|
||||
moveMode
|
||||
).open();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function getIndentationLevel(line: string): number {
|
|||
}
|
||||
|
||||
// --- Refactored Task Parsing using taskUtil ---
|
||||
function parseTasksForSorting(
|
||||
export function parseTasksForSorting(
|
||||
blockText: string,
|
||||
lineOffset: number = 0,
|
||||
filePath: string, // Added filePath
|
||||
|
|
@ -103,8 +103,8 @@ function parseTasksForSorting(
|
|||
if (
|
||||
!parsedTask.completed &&
|
||||
parsedTask.status !== SortableTaskStatus.Cancelled && // Compare against enum
|
||||
parsedTask.dueDate &&
|
||||
parsedTask.dueDate < todayTimestamp
|
||||
parsedTask.metadata.dueDate &&
|
||||
parsedTask.metadata.dueDate < todayTimestamp
|
||||
) {
|
||||
calculatedStatus = SortableTaskStatus.Overdue; // Use enum
|
||||
} else {
|
||||
|
|
@ -124,16 +124,17 @@ function parseTasksForSorting(
|
|||
status: parsedTask.status,
|
||||
completed: parsedTask.completed,
|
||||
content: parsedTask.content,
|
||||
priority: parsedTask.priority,
|
||||
dueDate: parsedTask.dueDate,
|
||||
startDate: parsedTask.startDate,
|
||||
scheduledDate: parsedTask.scheduledDate,
|
||||
tags: parsedTask.tags || [], // Map tags, default to empty array
|
||||
priority: parsedTask.metadata.priority,
|
||||
dueDate: parsedTask.metadata.dueDate,
|
||||
startDate: parsedTask.metadata.startDate,
|
||||
scheduledDate: parsedTask.metadata.scheduledDate,
|
||||
tags: parsedTask.metadata.tags || [], // Map tags, default to empty array
|
||||
// Fields specific to SortableTask / required for sorting logic
|
||||
lineNumber: lineNumber, // Keep 0-based line number for sorting stability
|
||||
indentation: indentation,
|
||||
children: [],
|
||||
calculatedStatus: calculatedStatus,
|
||||
metadata: parsedTask.metadata,
|
||||
// parent will be set below
|
||||
};
|
||||
|
||||
|
|
@ -396,12 +397,14 @@ function compareTasks<
|
|||
|
||||
tags: (a: T, b: T, order: "asc" | "desc") => {
|
||||
// Sort by tags - convert array to string for comparison
|
||||
const tagsA = Array.isArray((a as any).tags) && (a as any).tags.length > 0
|
||||
? (a as any).tags.join(", ")
|
||||
: null;
|
||||
const tagsB = Array.isArray((b as any).tags) && (b as any).tags.length > 0
|
||||
? (b as any).tags.join(", ")
|
||||
: null;
|
||||
const tagsA =
|
||||
Array.isArray((a as any).tags) && (a as any).tags.length > 0
|
||||
? (a as any).tags.join(", ")
|
||||
: null;
|
||||
const tagsB =
|
||||
Array.isArray((b as any).tags) && (b as any).tags.length > 0
|
||||
? (b as any).tags.join(", ")
|
||||
: null;
|
||||
|
||||
// Handle null/empty values - empty values should always go to the end
|
||||
if (!tagsA && !tagsB) return 0;
|
||||
|
|
@ -695,8 +698,6 @@ export function sortTasksInDocument(
|
|||
const settings = plugin.settings;
|
||||
const metadataFormat: MetadataFormat = settings.preferMetadataFormat;
|
||||
|
||||
console.log("settings", settings.sortCriteria);
|
||||
|
||||
// --- Get sortCriteria from settings ---
|
||||
const sortCriteria = settings.sortCriteria || DEFAULT_SETTINGS.sortCriteria; // Get from settings, use default if missing
|
||||
if (!settings.sortTasks || !sortCriteria || sortCriteria.length === 0) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import TaskProgressBarPlugin from "../index";
|
||||
import { buildIndentString } from "../utils";
|
||||
import { t } from "../translations/helper";
|
||||
import { isSupportedFile } from "../utils/fileTypeUtils";
|
||||
|
||||
/**
|
||||
* Modal for selecting a target file to move tasks to
|
||||
|
|
@ -39,11 +40,12 @@ export class FileSelectionModal extends FuzzySuggestModal<TFile | string> {
|
|||
}
|
||||
|
||||
getItems(): (TFile | string)[] {
|
||||
// Get all markdown files
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
// Get all supported files (markdown and canvas)
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
const supportedFiles = allFiles.filter(file => isSupportedFile(file));
|
||||
|
||||
// Filter out the current file
|
||||
const filteredFiles = files.filter(
|
||||
const filteredFiles = supportedFiles.filter(
|
||||
(file) => file.path !== this.currentFile.path
|
||||
);
|
||||
|
||||
|
|
@ -545,16 +547,23 @@ export function moveTaskCommand(
|
|||
const currentFile = ctx.file;
|
||||
|
||||
if (checking) {
|
||||
// If checking, return true if we're in a markdown file and cursor is on a task line
|
||||
if (!currentFile || currentFile.extension !== "md") {
|
||||
// If checking, return true if we're in a supported file and cursor is on a task line
|
||||
if (!currentFile || !isSupportedFile(currentFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
// For markdown files, check if cursor is on a task line
|
||||
if (currentFile.extension === "md") {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
// Check if line is a task with any of the supported list markers (-, 1., *)
|
||||
return line.match(/^\s*(-|\d+\.|\*) \[(.)\]/i) !== null;
|
||||
// Check if line is a task with any of the supported list markers (-, 1., *)
|
||||
return line.match(/^\s*(-|\d+\.|\*) \[(.)\]/i) !== null;
|
||||
}
|
||||
|
||||
// For canvas files, we don't support direct editing yet
|
||||
// This command is primarily for markdown files
|
||||
return false;
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
|
|
|
|||
374
src/commands/workflowCommands.ts
Normal file
374
src/commands/workflowCommands.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { Editor, MarkdownView, MarkdownFileInfo, Notice, Menu } from "obsidian";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { QuickWorkflowModal } from "../components/QuickWorkflowModal";
|
||||
import { WorkflowDefinitionModal } from "../components/WorkflowDefinitionModal";
|
||||
import {
|
||||
analyzeTaskStructure,
|
||||
convertTaskStructureToWorkflow,
|
||||
createWorkflowStartingTask,
|
||||
convertCurrentTaskToWorkflowRoot,
|
||||
suggestWorkflowFromExisting,
|
||||
} from "../utils/workflowConversion";
|
||||
import { t } from "../translations/helper";
|
||||
|
||||
/**
|
||||
* Command to create a quick workflow
|
||||
*/
|
||||
export function createQuickWorkflowCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
return plugin.settings.workflow.enableWorkflow;
|
||||
}
|
||||
|
||||
new QuickWorkflowModal(plugin.app, plugin, (workflow) => {
|
||||
// Add the workflow to settings
|
||||
plugin.settings.workflow.definitions.push(workflow);
|
||||
plugin.saveSettings();
|
||||
new Notice(t("Workflow created successfully"));
|
||||
}).open();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to convert current task structure to workflow template
|
||||
*/
|
||||
export function convertTaskToWorkflowCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
if (!plugin.settings.workflow.enableWorkflow) return false;
|
||||
|
||||
// Check if cursor is on or near a task
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
return line.match(/^\s*[-*+] \[(.)\]/) !== null;
|
||||
}
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
const structure = analyzeTaskStructure(editor, cursor);
|
||||
|
||||
if (!structure || !structure.isTask) {
|
||||
new Notice(t("No task structure found at cursor position"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for existing similar workflows
|
||||
const suggestion = suggestWorkflowFromExisting(
|
||||
structure,
|
||||
plugin.settings.workflow.definitions
|
||||
);
|
||||
|
||||
if (suggestion) {
|
||||
// Show a choice between using existing pattern or creating new
|
||||
const menu = new Menu();
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Use similar existing workflow"))
|
||||
.setIcon("copy")
|
||||
.onClick(() => {
|
||||
createWorkflowFromStructure(structure, suggestion.name, suggestion.id, plugin);
|
||||
});
|
||||
});
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Create new workflow"))
|
||||
.setIcon("plus")
|
||||
.onClick(() => {
|
||||
promptForWorkflowDetails(structure, plugin);
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
} else {
|
||||
promptForWorkflowDetails(structure, plugin);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to start a workflow at current position
|
||||
*/
|
||||
export function startWorkflowHereCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
return plugin.settings.workflow.enableWorkflow &&
|
||||
plugin.settings.workflow.definitions.length > 0;
|
||||
}
|
||||
|
||||
const workflows = plugin.settings.workflow.definitions;
|
||||
|
||||
if (workflows.length === 0) {
|
||||
new Notice(t("No workflows defined. Create a workflow first."));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (workflows.length === 1) {
|
||||
// If only one workflow, use it directly
|
||||
const cursor = editor.getCursor();
|
||||
createWorkflowStartingTask(editor, cursor, workflows[0], plugin);
|
||||
new Notice(t("Workflow task created"));
|
||||
} else {
|
||||
// Show workflow selection menu
|
||||
const menu = new Menu();
|
||||
|
||||
workflows.forEach((workflow) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(workflow.name)
|
||||
.setIcon("workflow")
|
||||
.onClick(() => {
|
||||
const cursor = editor.getCursor();
|
||||
createWorkflowStartingTask(editor, cursor, workflow, plugin);
|
||||
new Notice(t("Workflow task created"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to convert current task to workflow root
|
||||
*/
|
||||
export function convertToWorkflowRootCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
if (!plugin.settings.workflow.enableWorkflow) return false;
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
const taskMatch = line.match(/^\s*[-*+] \[(.)\]/);
|
||||
|
||||
// Check if it's a task and doesn't already have a workflow tag
|
||||
return taskMatch !== null && !line.includes("#workflow/");
|
||||
}
|
||||
|
||||
const workflows = plugin.settings.workflow.definitions;
|
||||
|
||||
if (workflows.length === 0) {
|
||||
new Notice(t("No workflows defined. Create a workflow first."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = editor.getCursor();
|
||||
|
||||
if (workflows.length === 1) {
|
||||
// If only one workflow, use it directly
|
||||
const success = convertCurrentTaskToWorkflowRoot(editor, cursor, workflows[0].id);
|
||||
if (success) {
|
||||
new Notice(t("Task converted to workflow root"));
|
||||
} else {
|
||||
new Notice(t("Failed to convert task"));
|
||||
}
|
||||
} else {
|
||||
// Show workflow selection menu
|
||||
const menu = new Menu();
|
||||
|
||||
workflows.forEach((workflow) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(workflow.name)
|
||||
.setIcon("workflow")
|
||||
.onClick(() => {
|
||||
const success = convertCurrentTaskToWorkflowRoot(editor, cursor, workflow.id);
|
||||
if (success) {
|
||||
new Notice(t("Task converted to workflow root"));
|
||||
} else {
|
||||
new Notice(t("Failed to convert task"));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to duplicate an existing workflow
|
||||
*/
|
||||
export function duplicateWorkflowCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
return plugin.settings.workflow.enableWorkflow &&
|
||||
plugin.settings.workflow.definitions.length > 0;
|
||||
}
|
||||
|
||||
const workflows = plugin.settings.workflow.definitions;
|
||||
|
||||
if (workflows.length === 0) {
|
||||
new Notice(t("No workflows to duplicate"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show workflow selection menu for duplication
|
||||
const menu = new Menu();
|
||||
|
||||
workflows.forEach((workflow) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Duplicate") + ": " + workflow.name)
|
||||
.setIcon("copy")
|
||||
.onClick(() => {
|
||||
const duplicatedWorkflow = {
|
||||
...workflow,
|
||||
id: workflow.id + "_copy",
|
||||
name: workflow.name + " (Copy)",
|
||||
metadata: {
|
||||
...workflow.metadata,
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
lastModified: new Date().toISOString().split("T")[0],
|
||||
}
|
||||
};
|
||||
|
||||
// Open the workflow definition modal for editing
|
||||
new WorkflowDefinitionModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
duplicatedWorkflow,
|
||||
(editedWorkflow) => {
|
||||
plugin.settings.workflow.definitions.push(editedWorkflow);
|
||||
plugin.saveSettings();
|
||||
new Notice(t("Workflow duplicated and saved"));
|
||||
}
|
||||
).open();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to prompt for workflow details
|
||||
*/
|
||||
function promptForWorkflowDetails(structure: any, plugin: TaskProgressBarPlugin) {
|
||||
// Create a simple prompt for workflow name
|
||||
const workflowName = structure.content + " Workflow";
|
||||
const workflowId = workflowName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.substring(0, 30);
|
||||
|
||||
createWorkflowFromStructure(structure, workflowName, workflowId, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create workflow from structure
|
||||
*/
|
||||
function createWorkflowFromStructure(
|
||||
structure: any,
|
||||
name: string,
|
||||
id: string,
|
||||
plugin: TaskProgressBarPlugin
|
||||
) {
|
||||
const workflow = convertTaskStructureToWorkflow(structure, name, id);
|
||||
|
||||
// Open the workflow definition modal for review and editing
|
||||
new WorkflowDefinitionModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
workflow,
|
||||
(finalWorkflow) => {
|
||||
plugin.settings.workflow.definitions.push(finalWorkflow);
|
||||
plugin.saveSettings();
|
||||
new Notice(t("Workflow created from task structure"));
|
||||
}
|
||||
).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Command to show workflow quick actions menu
|
||||
*/
|
||||
export function showWorkflowQuickActionsCommand(
|
||||
checking: boolean,
|
||||
editor: Editor,
|
||||
ctx: MarkdownView | MarkdownFileInfo,
|
||||
plugin: TaskProgressBarPlugin
|
||||
): boolean {
|
||||
if (checking) {
|
||||
return plugin.settings.workflow.enableWorkflow;
|
||||
}
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
// Quick workflow creation
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Create Quick Workflow"))
|
||||
.setIcon("plus-circle")
|
||||
.onClick(() => {
|
||||
createQuickWorkflowCommand(false, editor, ctx, plugin);
|
||||
});
|
||||
});
|
||||
|
||||
// Convert task to workflow
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
if (line.match(/^\s*[-*+] \[(.)\]/)) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Convert Task to Workflow"))
|
||||
.setIcon("convert")
|
||||
.onClick(() => {
|
||||
convertTaskToWorkflowCommand(false, editor, ctx, plugin);
|
||||
});
|
||||
});
|
||||
|
||||
if (!line.includes("#workflow/")) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Convert to Workflow Root"))
|
||||
.setIcon("workflow")
|
||||
.onClick(() => {
|
||||
convertToWorkflowRootCommand(false, editor, ctx, plugin);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start workflow here
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Start Workflow Here"))
|
||||
.setIcon("play")
|
||||
.onClick(() => {
|
||||
startWorkflowHereCommand(false, editor, ctx, plugin);
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate workflow
|
||||
if (plugin.settings.workflow.definitions.length > 0) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Duplicate Workflow"))
|
||||
.setIcon("copy")
|
||||
.onClick(() => {
|
||||
duplicateWorkflowCommand(false, editor, ctx, plugin);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
menu.showAtMouseEvent(window.event as MouseEvent);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -12,7 +12,69 @@ const EMOJI_PRIORITY_REGEX = /(([🔺⏫🔼🔽⏬️⏬])|(\[#[A-E]\]))/u; //
|
|||
const EMOJI_CONTEXT_REGEX = /@([\w-]+)/g;
|
||||
const EMOJI_TAG_REGEX =
|
||||
/#[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g; // Includes #project/ tags
|
||||
const EMOJI_PROJECT_PREFIX = "#project/";
|
||||
const EMOJI_PROJECT_PREFIX = "#project/"; // Keep for backward compatibility
|
||||
|
||||
// Format types for prefix generation
|
||||
export type MetadataFormat = "emoji" | "dataview";
|
||||
|
||||
// Function to get configurable project prefix based on format
|
||||
export const getProjectPrefix = (
|
||||
prefix?: string,
|
||||
format: MetadataFormat = "emoji"
|
||||
): string => {
|
||||
const projectPrefix = prefix || "project";
|
||||
if (format === "dataview") {
|
||||
return `[${projectPrefix}::`;
|
||||
} else {
|
||||
return `#${projectPrefix}/`;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get configurable context prefix based on format
|
||||
// Special handling: emoji format always uses @, dataview format uses configurable prefix
|
||||
export const getContextPrefix = (
|
||||
prefix?: string,
|
||||
format: MetadataFormat = "emoji"
|
||||
): string => {
|
||||
if (format === "dataview") {
|
||||
const contextPrefix = prefix || "context";
|
||||
return `[${contextPrefix}::`;
|
||||
} else {
|
||||
// For emoji format, always use @ (not configurable)
|
||||
return "@";
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get configurable area prefix based on format
|
||||
export const getAreaPrefix = (
|
||||
prefix?: string,
|
||||
format: MetadataFormat = "emoji"
|
||||
): string => {
|
||||
const areaPrefix = prefix || "area";
|
||||
if (format === "dataview") {
|
||||
return `[${areaPrefix}::`;
|
||||
} else {
|
||||
return `#${areaPrefix}/`;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to create dynamic regex for project tags
|
||||
export const createProjectRegex = (prefix?: string): RegExp => {
|
||||
const projectPrefix = prefix || "project";
|
||||
return new RegExp(`#${projectPrefix}/([\\w-]+)`, "g");
|
||||
};
|
||||
|
||||
// Function to create dynamic regex for dataview project fields
|
||||
export const createDataviewProjectRegex = (prefix?: string): RegExp => {
|
||||
const projectPrefix = prefix || "project";
|
||||
return new RegExp(`\\[${projectPrefix}::\\s*([^\\]]+)\\]`, "i");
|
||||
};
|
||||
|
||||
// Function to create dynamic regex for dataview context fields
|
||||
export const createDataviewContextRegex = (prefix?: string): RegExp => {
|
||||
const contextPrefix = prefix || "context";
|
||||
return new RegExp(`\\[${contextPrefix}::\\s*([^\\]]+)\\]`, "i");
|
||||
};
|
||||
|
||||
// --- Dataview Style Regexes ---
|
||||
const DV_START_DATE_REGEX = /\[(?:start|🛫)::\s*(\d{4}-\d{2}-\d{2})\]/i;
|
||||
|
|
@ -23,8 +85,8 @@ const DV_SCHEDULED_DATE_REGEX = /\[(?:scheduled|⏳)::\s*(\d{4}-\d{2}-\d{2})\]/i
|
|||
const DV_CREATED_DATE_REGEX = /\[(?:created|➕)::\s*(\d{4}-\d{2}-\d{2})\]/i;
|
||||
const DV_RECURRENCE_REGEX = /\[(?:repeat|recurrence|🔁)::\s*([^\]]+)\]/i;
|
||||
const DV_PRIORITY_REGEX = /\[priority::\s*([^\]]+)\]/i;
|
||||
const DV_PROJECT_REGEX = /\[project::\s*([^\]]+)\]/i;
|
||||
const DV_CONTEXT_REGEX = /\[context::\s*([^\]]+)\]/i;
|
||||
const DV_PROJECT_REGEX = /\[project::\s*([^\]]+)\]/i; // Default project regex
|
||||
const DV_CONTEXT_REGEX = /\[context::\s*([^\]]+)\]/i; // Default context regex
|
||||
// Dataview Tag Regex is the same, applied after DV field removal
|
||||
const ANY_DATAVIEW_FIELD_REGEX = /\[\w+(?:|🗓️|✅|➕|🛫|⏳|🔁)::\s*[^\]]+\]/gi;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { t } from "../translations/helper";
|
|||
import type TaskProgressBarPlugin from "../index"; // Type-only import
|
||||
import { BaseHabitData } from "../types/habit-card";
|
||||
import type { RootFilterState } from "../components/task-filter/ViewTaskFilter";
|
||||
import { IcsManagerConfig } from "../types/ics";
|
||||
|
||||
// Interface for individual project review settings (If still needed, otherwise remove)
|
||||
// Keep it for now, in case it's used elsewhere, but it's not part of TaskProgressBarSettings anymore
|
||||
|
|
@ -112,6 +113,7 @@ export interface KanbanColumnConfig {
|
|||
export interface CalendarSpecificConfig {
|
||||
viewType: "calendar"; // Discriminator
|
||||
firstDayOfWeek?: number; // 0=Sun, 1=Mon, ..., 6=Sat; undefined=locale default
|
||||
hideWeekends?: boolean; // Whether to hide weekend columns/cells in calendar views
|
||||
}
|
||||
|
||||
export interface GanttSpecificConfig {
|
||||
|
|
@ -123,6 +125,7 @@ export interface GanttSpecificConfig {
|
|||
export interface ForecastSpecificConfig {
|
||||
viewType: "forecast"; // Discriminator
|
||||
firstDayOfWeek?: number; // 0=Sun, 1=Mon, ..., 6=Sat; undefined=locale default
|
||||
hideWeekends?: boolean; // Whether to hide weekend columns/cells in forecast calendar
|
||||
}
|
||||
|
||||
export interface TwoColumnSpecificConfig {
|
||||
|
|
@ -209,6 +212,11 @@ export interface CompletedTaskMoverSettings {
|
|||
treatAbandonedAsCompleted: boolean;
|
||||
completeAllMovedTasks: boolean;
|
||||
withCurrentFileLink: boolean;
|
||||
// Default file and location settings for auto-move
|
||||
enableAutoMove: boolean;
|
||||
defaultTargetFile: string;
|
||||
defaultInsertionMode: "beginning" | "end" | "after-heading";
|
||||
defaultHeadingName: string; // Used when defaultInsertionMode is "after-heading"
|
||||
// Settings for incomplete task mover
|
||||
enableIncompletedTaskMover: boolean;
|
||||
incompletedTaskMarkerType: "version" | "date" | "custom";
|
||||
|
|
@ -216,6 +224,11 @@ export interface CompletedTaskMoverSettings {
|
|||
incompletedDateMarker: string;
|
||||
incompletedCustomMarker: string;
|
||||
withCurrentFileLinkForIncompleted: boolean;
|
||||
// Default settings for incomplete task auto-move
|
||||
enableIncompletedAutoMove: boolean;
|
||||
incompletedDefaultTargetFile: string;
|
||||
incompletedDefaultInsertionMode: "beginning" | "end" | "after-heading";
|
||||
incompletedDefaultHeadingName: string;
|
||||
}
|
||||
|
||||
/** Define the structure for quick capture settings */
|
||||
|
|
@ -224,6 +237,15 @@ export interface QuickCaptureSettings {
|
|||
targetFile: string;
|
||||
placeholder: string;
|
||||
appendToFile: "append" | "prepend" | "replace";
|
||||
// New settings for enhanced quick capture
|
||||
targetType: "fixed" | "daily-note"; // Target type: fixed file or daily note
|
||||
targetHeading?: string; // Optional heading to append under
|
||||
// Daily note settings
|
||||
dailyNoteSettings: {
|
||||
format: string; // Date format for daily notes (e.g., "YYYY-MM-DD")
|
||||
folder: string; // Folder path for daily notes
|
||||
template: string; // Template file path for daily notes
|
||||
};
|
||||
}
|
||||
|
||||
/** Define the structure for task gutter settings */
|
||||
|
|
@ -339,6 +361,103 @@ export interface BetaTestSettings {
|
|||
enableBaseView: boolean;
|
||||
}
|
||||
|
||||
/** Project path mapping configuration */
|
||||
export interface ProjectPathMapping {
|
||||
/** Path pattern (supports glob patterns) */
|
||||
pathPattern: string;
|
||||
/** Project name for this path */
|
||||
projectName: string;
|
||||
/** Whether this mapping is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Project metadata configuration */
|
||||
export interface ProjectMetadataConfig {
|
||||
/** Metadata key to use for project name */
|
||||
metadataKey: string;
|
||||
/** Whether to inherit from file frontmatter */
|
||||
inheritFromFrontmatter: boolean;
|
||||
/** Whether subtasks should inherit metadata from file frontmatter */
|
||||
inheritFromFrontmatterForSubtasks: boolean;
|
||||
/** Whether this config is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Project configuration file settings */
|
||||
export interface ProjectConfigFile {
|
||||
/** Name of the project configuration file */
|
||||
fileName: string;
|
||||
/** Whether to search recursively up the directory tree */
|
||||
searchRecursively: boolean;
|
||||
/** Whether this feature is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Metadata mapping configuration */
|
||||
export interface MetadataMapping {
|
||||
/** Source metadata key */
|
||||
sourceKey: string;
|
||||
/** Target metadata key */
|
||||
targetKey: string;
|
||||
/** Whether this mapping is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Default project naming strategy */
|
||||
export interface ProjectNamingStrategy {
|
||||
/** Naming strategy type */
|
||||
strategy: "filename" | "foldername" | "metadata";
|
||||
/** Metadata key for metadata strategy */
|
||||
metadataKey?: string;
|
||||
/** Whether to strip file extension for filename strategy */
|
||||
stripExtension?: boolean;
|
||||
/** Whether this strategy is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Enhanced project configuration */
|
||||
export interface ProjectConfiguration {
|
||||
/** Path-based project mappings */
|
||||
pathMappings: ProjectPathMapping[];
|
||||
/** Metadata-based project configuration */
|
||||
metadataConfig: ProjectMetadataConfig;
|
||||
/** Project configuration file settings */
|
||||
configFile: ProjectConfigFile;
|
||||
/** Whether to enable enhanced project features */
|
||||
enableEnhancedProject: boolean;
|
||||
/** Metadata key mappings */
|
||||
metadataMappings: MetadataMapping[];
|
||||
/** Default project naming strategy */
|
||||
defaultProjectNaming: ProjectNamingStrategy;
|
||||
}
|
||||
|
||||
/** File parsing configuration for extracting tasks from file metadata and tags */
|
||||
export interface FileParsingConfiguration {
|
||||
/** Enable parsing tasks from file metadata */
|
||||
enableFileMetadataParsing: boolean;
|
||||
/** Metadata fields that should be treated as tasks (e.g., "dueDate", "todo", "complete") */
|
||||
metadataFieldsToParseAsTasks: string[];
|
||||
/** Enable parsing tasks from file tags */
|
||||
enableTagBasedTaskParsing: boolean;
|
||||
/** Tags that should be treated as tasks (e.g., "#todo", "#task", "#action") */
|
||||
tagsToParseAsTasks: string[];
|
||||
/** Which metadata field to use as task content (default: "title" or filename) */
|
||||
taskContentFromMetadata: string;
|
||||
/** Default status for tasks created from metadata (default: " " for incomplete) */
|
||||
defaultTaskStatus: string;
|
||||
/** Whether to use worker for file parsing performance */
|
||||
enableWorkerProcessing: boolean;
|
||||
}
|
||||
|
||||
/** Timeline Sidebar Settings */
|
||||
export interface TimelineSidebarSettings {
|
||||
enableTimelineSidebar: boolean;
|
||||
autoOpenOnStartup: boolean;
|
||||
showCompletedTasks: boolean;
|
||||
focusModeByDefault: boolean;
|
||||
maxEventsToShow: number;
|
||||
}
|
||||
|
||||
/** Define the main settings structure */
|
||||
export interface TaskProgressBarSettings {
|
||||
// General Settings (Example)
|
||||
|
|
@ -376,6 +495,8 @@ export interface TaskProgressBarSettings {
|
|||
taskStatusMarks: TaskStatusCycle;
|
||||
excludeMarksFromCycle: string[];
|
||||
|
||||
enableTaskGeniusIcons: boolean;
|
||||
|
||||
// Priority & Date Settings
|
||||
enablePriorityPicker: boolean;
|
||||
enablePriorityKeyboardShortcuts: boolean;
|
||||
|
|
@ -404,6 +525,17 @@ export interface TaskProgressBarSettings {
|
|||
dailyNotePath: string;
|
||||
preferMetadataFormat: "dataview" | "tasks";
|
||||
|
||||
// Task Parser Configuration
|
||||
projectTagPrefix: Record<"dataview" | "tasks", string>; // Configurable project tag prefix (default: "project")
|
||||
contextTagPrefix: Record<"dataview" | "tasks", string>; // Configurable context tag prefix (default: "context")
|
||||
areaTagPrefix: Record<"dataview" | "tasks", string>; // Configurable area tag prefix (default: "area")
|
||||
|
||||
// Enhanced Project Configuration
|
||||
projectConfig: ProjectConfiguration;
|
||||
|
||||
// File Parsing Configuration
|
||||
fileParsingConfig: FileParsingConfiguration;
|
||||
|
||||
// Date Settings
|
||||
useRelativeTimeForDate: boolean;
|
||||
|
||||
|
|
@ -439,17 +571,23 @@ export interface TaskProgressBarSettings {
|
|||
|
||||
// Beta Test Settings
|
||||
betaTest?: BetaTestSettings;
|
||||
|
||||
// ICS Calendar Integration Settings
|
||||
icsIntegration: IcsManagerConfig;
|
||||
|
||||
// Timeline Sidebar Settings
|
||||
timelineSidebar: TimelineSidebarSettings;
|
||||
}
|
||||
|
||||
/** Define the default settings */
|
||||
export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
||||
// General Defaults
|
||||
progressBarDisplayMode: "both",
|
||||
supportHoverToShowProgressInfo: true,
|
||||
supportHoverToShowProgressInfo: false,
|
||||
addProgressBarToNonTaskBullet: false,
|
||||
addTaskProgressBarToHeading: true,
|
||||
enableProgressbarInReadingMode: true,
|
||||
countSubLevel: true,
|
||||
addTaskProgressBarToHeading: false,
|
||||
enableProgressbarInReadingMode: false,
|
||||
countSubLevel: false,
|
||||
displayMode: "bracketFraction",
|
||||
customFormat: "[{{COMPLETED}}/{{TOTAL}}]",
|
||||
showPercentage: false,
|
||||
|
|
@ -501,6 +639,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
Planned: "?",
|
||||
},
|
||||
excludeMarksFromCycle: [],
|
||||
enableTaskGeniusIcons: false,
|
||||
|
||||
// Priority & Date Defaults
|
||||
enablePriorityPicker: false,
|
||||
|
|
@ -510,7 +649,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
|
||||
// Task Filter Defaults
|
||||
taskFilter: {
|
||||
enableTaskFilter: true,
|
||||
enableTaskFilter: false,
|
||||
presetTaskFilters: [], // Start empty, maybe add defaults later or via a reset button
|
||||
},
|
||||
|
||||
|
|
@ -521,7 +660,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
|
||||
// Completed Task Mover Defaults
|
||||
completedTaskMover: {
|
||||
enableCompletedTaskMover: true,
|
||||
enableCompletedTaskMover: false,
|
||||
taskMarkerType: "date",
|
||||
versionMarker: "version 1.0",
|
||||
dateMarker: t("archived on") + " {{date}}",
|
||||
|
|
@ -529,6 +668,11 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
treatAbandonedAsCompleted: false,
|
||||
completeAllMovedTasks: true,
|
||||
withCurrentFileLink: true,
|
||||
// Auto-move defaults for completed tasks
|
||||
enableAutoMove: false,
|
||||
defaultTargetFile: "Archive.md",
|
||||
defaultInsertionMode: "end",
|
||||
defaultHeadingName: "Completed Tasks",
|
||||
// Incomplete Task Mover Defaults
|
||||
enableIncompletedTaskMover: true,
|
||||
incompletedTaskMarkerType: "date",
|
||||
|
|
@ -536,14 +680,26 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
incompletedDateMarker: t("moved on") + " {{date}}",
|
||||
incompletedCustomMarker: t("moved") + " {{DATE:YYYY-MM-DD HH:mm}}",
|
||||
withCurrentFileLinkForIncompleted: true,
|
||||
// Auto-move defaults for incomplete tasks
|
||||
enableIncompletedAutoMove: false,
|
||||
incompletedDefaultTargetFile: "Backlog.md",
|
||||
incompletedDefaultInsertionMode: "end",
|
||||
incompletedDefaultHeadingName: "Incomplete Tasks",
|
||||
},
|
||||
|
||||
// Quick Capture Defaults
|
||||
quickCapture: {
|
||||
enableQuickCapture: true,
|
||||
enableQuickCapture: false,
|
||||
targetFile: "QuickCapture.md",
|
||||
placeholder: t("Capture your thoughts..."),
|
||||
appendToFile: "append",
|
||||
targetType: "fixed",
|
||||
targetHeading: "",
|
||||
dailyNoteSettings: {
|
||||
format: "YYYY-MM-DD",
|
||||
folder: "",
|
||||
template: "",
|
||||
},
|
||||
},
|
||||
|
||||
// Workflow Defaults
|
||||
|
|
@ -620,6 +776,54 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
dailyNotePath: "",
|
||||
preferMetadataFormat: "tasks",
|
||||
|
||||
// Task Parser Configuration
|
||||
projectTagPrefix: {
|
||||
tasks: "project",
|
||||
dataview: "project",
|
||||
},
|
||||
contextTagPrefix: {
|
||||
tasks: "@",
|
||||
dataview: "context",
|
||||
},
|
||||
areaTagPrefix: {
|
||||
tasks: "area",
|
||||
dataview: "area",
|
||||
},
|
||||
|
||||
// Enhanced Project Configuration
|
||||
projectConfig: {
|
||||
enableEnhancedProject: false,
|
||||
pathMappings: [],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
inheritFromFrontmatter: false,
|
||||
inheritFromFrontmatterForSubtasks: false,
|
||||
enabled: false,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "project.md",
|
||||
searchRecursively: false,
|
||||
enabled: false,
|
||||
},
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename" as const,
|
||||
stripExtension: false,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
// File Parsing Configuration
|
||||
fileParsingConfig: {
|
||||
enableFileMetadataParsing: false,
|
||||
metadataFieldsToParseAsTasks: ["dueDate", "todo", "complete", "task"],
|
||||
enableTagBasedTaskParsing: false,
|
||||
tagsToParseAsTasks: ["#todo", "#task", "#action", "#due"],
|
||||
taskContentFromMetadata: "title",
|
||||
defaultTaskStatus: " ",
|
||||
enableWorkerProcessing: true,
|
||||
},
|
||||
|
||||
// Date Settings
|
||||
useRelativeTimeForDate: false,
|
||||
|
||||
|
|
@ -652,6 +856,11 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
hideCompletedAndAbandonedTasks: true,
|
||||
filterRules: {},
|
||||
filterBlanks: false,
|
||||
specificConfig: {
|
||||
viewType: "forecast",
|
||||
firstDayOfWeek: undefined, // Use locale default initially
|
||||
hideWeekends: false, // Show weekends by default
|
||||
} as ForecastSpecificConfig,
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
|
|
@ -705,6 +914,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
specificConfig: {
|
||||
viewType: "calendar",
|
||||
firstDayOfWeek: undefined, // Use locale default initially
|
||||
hideWeekends: false, // Show weekends by default
|
||||
} as CalendarSpecificConfig,
|
||||
},
|
||||
{
|
||||
|
|
@ -888,6 +1098,28 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
|
|||
betaTest: {
|
||||
enableBaseView: false,
|
||||
},
|
||||
|
||||
// ICS Calendar Integration Defaults
|
||||
icsIntegration: {
|
||||
sources: [],
|
||||
globalRefreshInterval: 60, // 1 hour
|
||||
maxCacheAge: 24, // 24 hours
|
||||
enableBackgroundRefresh: false,
|
||||
networkTimeout: 30, // 30 seconds
|
||||
maxEventsPerSource: 1000,
|
||||
showInCalendar: false,
|
||||
showInTaskLists: false,
|
||||
defaultEventColor: "#3b82f6", // Blue color
|
||||
},
|
||||
|
||||
// Timeline Sidebar Defaults
|
||||
timelineSidebar: {
|
||||
enableTimelineSidebar: false,
|
||||
autoOpenOnStartup: false,
|
||||
showCompletedTasks: true,
|
||||
focusModeByDefault: false,
|
||||
maxEventsToShow: 100,
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to get view settings safely
|
||||
|
|
|
|||
91
src/common/task-parser-config.ts
Normal file
91
src/common/task-parser-config.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { MetadataParseMode, TaskParserConfig } from "../types/TaskParserConfig";
|
||||
import { MetadataFormat } from "../utils/taskUtil";
|
||||
import type TaskProgressBarPlugin from "../index";
|
||||
|
||||
export const getConfig = (
|
||||
format: MetadataFormat,
|
||||
plugin?: TaskProgressBarPlugin
|
||||
): TaskParserConfig => {
|
||||
// Get configurable prefixes from plugin settings, with fallback defaults
|
||||
const projectPrefix =
|
||||
plugin?.settings?.projectTagPrefix[format] || "project";
|
||||
const contextPrefix =
|
||||
plugin?.settings?.contextTagPrefix[format] ||
|
||||
(format === "dataview" ? "context" : "@");
|
||||
const areaPrefix = plugin?.settings?.areaTagPrefix[format] || "area";
|
||||
|
||||
const config: TaskParserConfig = {
|
||||
// Basic parsing controls
|
||||
parseTags: true,
|
||||
parseMetadata: true,
|
||||
parseHeadings: false, // taskUtil functions are for single-line parsing
|
||||
parseComments: false, // Not needed for single-line parsing
|
||||
|
||||
// Metadata format preference
|
||||
metadataParseMode:
|
||||
format === "dataview"
|
||||
? MetadataParseMode.DataviewOnly
|
||||
: MetadataParseMode.Both,
|
||||
|
||||
// Status mapping (standard task states)
|
||||
statusMapping: {
|
||||
todo: " ",
|
||||
done: "x",
|
||||
cancelled: "-",
|
||||
forwarded: ">",
|
||||
scheduled: "<",
|
||||
important: "!",
|
||||
question: "?",
|
||||
incomplete: "/",
|
||||
paused: "p",
|
||||
pro: "P",
|
||||
con: "C",
|
||||
quote: "Q",
|
||||
note: "N",
|
||||
bookmark: "b",
|
||||
information: "i",
|
||||
savings: "S",
|
||||
idea: "I",
|
||||
location: "l",
|
||||
phone: "k",
|
||||
win: "w",
|
||||
key: "K",
|
||||
},
|
||||
|
||||
// Emoji to metadata mapping
|
||||
emojiMapping: {
|
||||
"📅": "dueDate",
|
||||
"🛫": "startDate",
|
||||
"⏳": "scheduledDate",
|
||||
"✅": "completedDate",
|
||||
"➕": "createdDate",
|
||||
"🔁": "recurrence",
|
||||
"🔺": "priority",
|
||||
"⏫": "priority",
|
||||
"🔼": "priority",
|
||||
"🔽": "priority",
|
||||
"⏬": "priority",
|
||||
},
|
||||
|
||||
// Special tag prefixes for project/context/area (now configurable)
|
||||
specialTagPrefixes: {
|
||||
[projectPrefix]: "project",
|
||||
[areaPrefix]: "area",
|
||||
[contextPrefix]: "context",
|
||||
},
|
||||
|
||||
// Performance and parsing limits
|
||||
maxParseIterations: 4000,
|
||||
maxMetadataIterations: 400,
|
||||
maxTagLength: 100,
|
||||
maxEmojiValueLength: 200,
|
||||
maxStackOperations: 4000,
|
||||
maxStackSize: 1000,
|
||||
maxIndentSize: 8,
|
||||
|
||||
// Enhanced project configuration
|
||||
projectConfig: plugin?.settings?.projectConfig,
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
|
|
@ -6,13 +6,14 @@ import {
|
|||
Notice,
|
||||
Platform,
|
||||
MarkdownRenderer,
|
||||
moment,
|
||||
} from "obsidian";
|
||||
import {
|
||||
createEmbeddableMarkdownEditor,
|
||||
EmbeddableMarkdownEditor,
|
||||
} from "../editor-ext/markdownEditor";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { saveCapture } from "../utils/fileUtils";
|
||||
import { saveCapture, processDateTemplates } from "../utils/fileUtils";
|
||||
import { FileSuggest } from "../editor-ext/quickCapture";
|
||||
import { t } from "../translations/helper";
|
||||
import { MarkdownRendererComponent } from "./MarkdownRenderer";
|
||||
|
|
@ -31,6 +32,20 @@ interface TaskMetadata {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename by replacing unsafe characters with safe alternatives
|
||||
* @param filename - The filename to sanitize
|
||||
* @returns The sanitized filename
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// Replace unsafe characters with safe alternatives
|
||||
return filename
|
||||
.replace(/[<>:"|*?\\]/g, "-") // Replace unsafe chars with dash
|
||||
.replace(/\//g, "-") // Replace forward slash with dash
|
||||
.replace(/\s+/g, " ") // Normalize whitespace
|
||||
.trim(); // Remove leading/trailing whitespace
|
||||
}
|
||||
|
||||
export class QuickCaptureModal extends Modal {
|
||||
plugin: TaskProgressBarPlugin;
|
||||
markdownEditor: EmbeddableMarkdownEditor | null = null;
|
||||
|
|
@ -53,7 +68,23 @@ export class QuickCaptureModal extends Modal {
|
|||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.tempTargetFilePath = this.plugin.settings.quickCapture.targetFile;
|
||||
|
||||
// Initialize target file path based on target type
|
||||
if (this.plugin.settings.quickCapture.targetType === "daily-note") {
|
||||
const dateStr = moment().format(
|
||||
this.plugin.settings.quickCapture.dailyNoteSettings.format
|
||||
);
|
||||
const sanitizedDateStr = sanitizeFilename(dateStr);
|
||||
const fileName = `${sanitizedDateStr}.md`;
|
||||
this.tempTargetFilePath = this.plugin.settings.quickCapture
|
||||
.dailyNoteSettings.folder
|
||||
? `${this.plugin.settings.quickCapture.dailyNoteSettings.folder}/${fileName}`
|
||||
: fileName;
|
||||
} else {
|
||||
this.tempTargetFilePath =
|
||||
this.plugin.settings.quickCapture.targetFile;
|
||||
}
|
||||
|
||||
this.preferMetadataFormat = this.plugin.settings.preferMetadataFormat;
|
||||
|
||||
if (metadata) {
|
||||
|
|
@ -83,7 +114,10 @@ export class QuickCaptureModal extends Modal {
|
|||
const targetFileEl = this.titleEl.createEl("div", {
|
||||
cls: "quick-capture-target",
|
||||
attr: {
|
||||
contenteditable: "true",
|
||||
contenteditable:
|
||||
this.plugin.settings.quickCapture.targetType === "fixed"
|
||||
? "true"
|
||||
: "false",
|
||||
spellcheck: "false",
|
||||
},
|
||||
text: this.tempTargetFilePath,
|
||||
|
|
@ -113,17 +147,20 @@ export class QuickCaptureModal extends Modal {
|
|||
});
|
||||
cancelButton.addEventListener("click", () => this.close());
|
||||
|
||||
new FileSuggest(
|
||||
this.app,
|
||||
targetFileEl,
|
||||
this.plugin.settings.quickCapture,
|
||||
(file: TFile) => {
|
||||
targetFileEl.textContent = file.path;
|
||||
this.tempTargetFilePath = file.path;
|
||||
// Focus current editor
|
||||
this.markdownEditor?.editor?.focus();
|
||||
}
|
||||
);
|
||||
// Only add file suggest for fixed file type
|
||||
if (this.plugin.settings.quickCapture.targetType === "fixed") {
|
||||
new FileSuggest(
|
||||
this.app,
|
||||
targetFileEl,
|
||||
this.plugin.settings.quickCapture,
|
||||
(file: TFile) => {
|
||||
targetFileEl.textContent = file.path;
|
||||
this.tempTargetFilePath = file.path;
|
||||
// Focus current editor
|
||||
this.markdownEditor?.editor?.focus();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createFullFeaturedModal(contentEl: HTMLElement) {
|
||||
|
|
@ -155,22 +192,28 @@ export class QuickCaptureModal extends Modal {
|
|||
const targetFileEl = targetFileContainer.createEl("div", {
|
||||
cls: "quick-capture-target",
|
||||
attr: {
|
||||
contenteditable: "true",
|
||||
contenteditable:
|
||||
this.plugin.settings.quickCapture.targetType === "fixed"
|
||||
? "true"
|
||||
: "false",
|
||||
spellcheck: "false",
|
||||
},
|
||||
text: this.tempTargetFilePath,
|
||||
});
|
||||
|
||||
new FileSuggest(
|
||||
this.app,
|
||||
targetFileEl,
|
||||
this.plugin.settings.quickCapture,
|
||||
(file: TFile) => {
|
||||
targetFileEl.textContent = file.path;
|
||||
this.tempTargetFilePath = file.path;
|
||||
this.markdownEditor?.editor?.focus();
|
||||
}
|
||||
);
|
||||
// Only add file suggest for fixed file type
|
||||
if (this.plugin.settings.quickCapture.targetType === "fixed") {
|
||||
new FileSuggest(
|
||||
this.app,
|
||||
targetFileEl,
|
||||
this.plugin.settings.quickCapture,
|
||||
(file: TFile) => {
|
||||
targetFileEl.textContent = file.path;
|
||||
this.tempTargetFilePath = file.path;
|
||||
this.markdownEditor?.editor?.focus();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Task metadata configuration
|
||||
configPanel.createDiv({
|
||||
|
|
@ -416,7 +459,13 @@ export class QuickCaptureModal extends Modal {
|
|||
"x",
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
targetFileEl.focus();
|
||||
// Only allow focus on target file if it's editable (fixed file type)
|
||||
if (
|
||||
this.plugin.settings.quickCapture.targetType ===
|
||||
"fixed"
|
||||
) {
|
||||
targetFileEl.focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
|
@ -440,10 +489,14 @@ export class QuickCaptureModal extends Modal {
|
|||
|
||||
try {
|
||||
const processedContent = this.processContentWithMetadata(content);
|
||||
await saveCapture(this.app, processedContent, {
|
||||
|
||||
// Create options with current settings
|
||||
const captureOptions = {
|
||||
...this.plugin.settings.quickCapture,
|
||||
targetFile: this.tempTargetFilePath,
|
||||
});
|
||||
};
|
||||
|
||||
await saveCapture(this.app, processedContent, captureOptions);
|
||||
new Notice(t("Captured successfully"));
|
||||
this.close();
|
||||
} catch (error) {
|
||||
|
|
@ -607,18 +660,38 @@ export class QuickCaptureModal extends Modal {
|
|||
// Add project if set
|
||||
if (this.taskMetadata.project) {
|
||||
if (useDataviewFormat) {
|
||||
metadata.push(`[project:: ${this.taskMetadata.project}]`);
|
||||
const projectPrefix =
|
||||
this.plugin.settings.projectTagPrefix[
|
||||
this.plugin.settings.preferMetadataFormat
|
||||
] || "project";
|
||||
metadata.push(
|
||||
`[${projectPrefix}:: ${this.taskMetadata.project}]`
|
||||
);
|
||||
} else {
|
||||
metadata.push(`#project/${this.taskMetadata.project}`);
|
||||
const projectPrefix =
|
||||
this.plugin.settings.projectTagPrefix[
|
||||
this.plugin.settings.preferMetadataFormat
|
||||
] || "project";
|
||||
metadata.push(`#${projectPrefix}/${this.taskMetadata.project}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add context if set
|
||||
if (this.taskMetadata.context) {
|
||||
if (useDataviewFormat) {
|
||||
metadata.push(`[context:: ${this.taskMetadata.context}]`);
|
||||
const contextPrefix =
|
||||
this.plugin.settings.contextTagPrefix[
|
||||
this.plugin.settings.preferMetadataFormat
|
||||
] || "context";
|
||||
metadata.push(
|
||||
`[${contextPrefix}:: ${this.taskMetadata.context}]`
|
||||
);
|
||||
} else {
|
||||
metadata.push(`@${this.taskMetadata.context}`);
|
||||
const contextPrefix =
|
||||
this.plugin.settings.contextTagPrefix[
|
||||
this.plugin.settings.preferMetadataFormat
|
||||
] || "@";
|
||||
metadata.push(`${contextPrefix}${this.taskMetadata.context}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
327
src/components/QuickWorkflowModal.ts
Normal file
327
src/components/QuickWorkflowModal.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import {
|
||||
App,
|
||||
Modal,
|
||||
Setting,
|
||||
Notice,
|
||||
ButtonComponent,
|
||||
DropdownComponent,
|
||||
} from "obsidian";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { WorkflowDefinition, WorkflowStage } from "../common/setting-definition";
|
||||
import { t } from "../translations/helper";
|
||||
|
||||
/**
|
||||
* Quick workflow creation modal for streamlined workflow setup
|
||||
*/
|
||||
export class QuickWorkflowModal extends Modal {
|
||||
plugin: TaskProgressBarPlugin;
|
||||
onSave: (workflow: WorkflowDefinition) => void;
|
||||
workflow: Partial<WorkflowDefinition>;
|
||||
templateType: string = "custom";
|
||||
|
||||
// Predefined workflow templates
|
||||
private templates = {
|
||||
simple: {
|
||||
name: t("Simple Linear Workflow"),
|
||||
description: t("A basic linear workflow with sequential stages"),
|
||||
stages: [
|
||||
{ id: "todo", name: t("To Do"), type: "linear" as const },
|
||||
{ id: "in_progress", name: t("In Progress"), type: "linear" as const },
|
||||
{ id: "done", name: t("Done"), type: "terminal" as const },
|
||||
],
|
||||
},
|
||||
project: {
|
||||
name: t("Project Management"),
|
||||
description: t("Standard project management workflow"),
|
||||
stages: [
|
||||
{ id: "planning", name: t("Planning"), type: "linear" as const },
|
||||
{
|
||||
id: "development",
|
||||
name: t("Development"),
|
||||
type: "cycle" as const,
|
||||
subStages: [
|
||||
{ id: "coding", name: t("Coding") },
|
||||
{ id: "testing", name: t("Testing") },
|
||||
],
|
||||
},
|
||||
{ id: "review", name: t("Review"), type: "linear" as const },
|
||||
{ id: "completed", name: t("Completed"), type: "terminal" as const },
|
||||
],
|
||||
},
|
||||
research: {
|
||||
name: t("Research Process"),
|
||||
description: t("Academic or professional research workflow"),
|
||||
stages: [
|
||||
{ id: "literature_review", name: t("Literature Review"), type: "linear" as const },
|
||||
{ id: "data_collection", name: t("Data Collection"), type: "cycle" as const },
|
||||
{ id: "analysis", name: t("Analysis"), type: "cycle" as const },
|
||||
{ id: "writing", name: t("Writing"), type: "linear" as const },
|
||||
{ id: "published", name: t("Published"), type: "terminal" as const },
|
||||
],
|
||||
},
|
||||
custom: {
|
||||
name: t("Custom Workflow"),
|
||||
description: t("Create a custom workflow from scratch"),
|
||||
stages: [],
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
onSave: (workflow: WorkflowDefinition) => void
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.onSave = onSave;
|
||||
this.workflow = {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
stages: [],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
lastModified: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl, titleEl } = this;
|
||||
this.modalEl.toggleClass("quick-workflow-modal", true);
|
||||
|
||||
titleEl.setText(t("Quick Workflow Creation"));
|
||||
|
||||
this.createTemplateSelection(contentEl);
|
||||
this.createWorkflowForm(contentEl);
|
||||
this.createButtons(contentEl);
|
||||
}
|
||||
|
||||
private createTemplateSelection(container: HTMLElement) {
|
||||
const templateSection = container.createDiv({ cls: "workflow-template-section" });
|
||||
|
||||
new Setting(templateSection)
|
||||
.setName(t("Workflow Template"))
|
||||
.setDesc(t("Choose a template to start with or create a custom workflow"))
|
||||
.addDropdown((dropdown) => {
|
||||
Object.entries(this.templates).forEach(([key, template]) => {
|
||||
dropdown.addOption(key, template.name);
|
||||
});
|
||||
|
||||
dropdown.setValue(this.templateType).onChange((value) => {
|
||||
this.templateType = value;
|
||||
this.applyTemplate();
|
||||
this.refreshForm();
|
||||
});
|
||||
});
|
||||
|
||||
// Template description
|
||||
const descContainer = templateSection.createDiv({ cls: "template-description" });
|
||||
this.updateTemplateDescription(descContainer);
|
||||
}
|
||||
|
||||
private createWorkflowForm(container: HTMLElement) {
|
||||
const formSection = container.createDiv({ cls: "workflow-form-section" });
|
||||
|
||||
// Basic workflow info
|
||||
new Setting(formSection)
|
||||
.setName(t("Workflow Name"))
|
||||
.setDesc(t("A descriptive name for your workflow"))
|
||||
.addText((text) => {
|
||||
text.setValue(this.workflow.name || "")
|
||||
.setPlaceholder(t("Enter workflow name"))
|
||||
.onChange((value) => {
|
||||
this.workflow.name = value;
|
||||
// Auto-generate ID if not manually set
|
||||
if (!this.workflow.id || this.workflow.id === this.generateIdFromName(this.workflow.name || "")) {
|
||||
this.workflow.id = this.generateIdFromName(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(formSection)
|
||||
.setName(t("Workflow ID"))
|
||||
.setDesc(t("Unique identifier (auto-generated from name)"))
|
||||
.addText((text) => {
|
||||
text.setValue(this.workflow.id || "")
|
||||
.setPlaceholder("workflow_id")
|
||||
.onChange((value) => {
|
||||
this.workflow.id = value;
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(formSection)
|
||||
.setName(t("Description"))
|
||||
.setDesc(t("Optional description of the workflow purpose"))
|
||||
.addTextArea((textarea) => {
|
||||
textarea
|
||||
.setValue(this.workflow.description || "")
|
||||
.setPlaceholder(t("Describe your workflow..."))
|
||||
.onChange((value) => {
|
||||
this.workflow.description = value;
|
||||
});
|
||||
textarea.inputEl.rows = 2;
|
||||
});
|
||||
|
||||
// Stages preview
|
||||
this.createStagesPreview(formSection);
|
||||
}
|
||||
|
||||
private createStagesPreview(container: HTMLElement) {
|
||||
const stagesSection = container.createDiv({ cls: "workflow-stages-preview" });
|
||||
|
||||
const stagesHeader = new Setting(stagesSection)
|
||||
.setName(t("Workflow Stages"))
|
||||
.setDesc(t("Preview of workflow stages (edit after creation for advanced options)"));
|
||||
|
||||
stagesHeader.addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add Stage"))
|
||||
.setIcon("plus")
|
||||
.onClick(() => {
|
||||
this.addQuickStage();
|
||||
});
|
||||
});
|
||||
|
||||
this.renderStagesPreview(stagesSection);
|
||||
}
|
||||
|
||||
private renderStagesPreview(container: HTMLElement) {
|
||||
// Clear existing preview
|
||||
const existingPreview = container.querySelector(".stages-preview-list");
|
||||
if (existingPreview) {
|
||||
existingPreview.remove();
|
||||
}
|
||||
|
||||
if (!this.workflow.stages || this.workflow.stages.length === 0) {
|
||||
container.createDiv({
|
||||
cls: "no-stages-message",
|
||||
text: t("No stages defined. Choose a template or add stages manually."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stagesList = container.createDiv({ cls: "stages-preview-list" });
|
||||
|
||||
this.workflow.stages.forEach((stage, index) => {
|
||||
const stageItem = stagesList.createDiv({ cls: "stage-preview-item" });
|
||||
|
||||
const stageInfo = stageItem.createDiv({ cls: "stage-info" });
|
||||
stageInfo.createSpan({ cls: "stage-name", text: stage.name });
|
||||
stageInfo.createSpan({ cls: "stage-type", text: `(${stage.type})` });
|
||||
|
||||
const stageActions = stageItem.createDiv({ cls: "stage-actions" });
|
||||
|
||||
// Remove button
|
||||
const removeBtn = new ButtonComponent(stageActions);
|
||||
removeBtn
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove stage"))
|
||||
.onClick(() => {
|
||||
this.workflow.stages?.splice(index, 1);
|
||||
this.renderStagesPreview(container);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private createButtons(container: HTMLElement) {
|
||||
const buttonContainer = container.createDiv({ cls: "workflow-modal-buttons" });
|
||||
|
||||
const cancelButton = buttonContainer.createEl("button", {
|
||||
text: t("Cancel"),
|
||||
cls: "workflow-cancel-button",
|
||||
});
|
||||
cancelButton.addEventListener("click", () => this.close());
|
||||
|
||||
const saveButton = buttonContainer.createEl("button", {
|
||||
text: t("Create Workflow"),
|
||||
cls: "workflow-save-button mod-cta",
|
||||
});
|
||||
saveButton.addEventListener("click", () => this.handleSave());
|
||||
}
|
||||
|
||||
private applyTemplate() {
|
||||
const template = this.templates[this.templateType as keyof typeof this.templates];
|
||||
if (template) {
|
||||
this.workflow.name = template.name;
|
||||
this.workflow.id = this.generateIdFromName(template.name);
|
||||
this.workflow.description = template.description;
|
||||
this.workflow.stages = JSON.parse(JSON.stringify(template.stages));
|
||||
}
|
||||
}
|
||||
|
||||
private updateTemplateDescription(container: HTMLElement) {
|
||||
container.empty();
|
||||
const template = this.templates[this.templateType as keyof typeof this.templates];
|
||||
if (template) {
|
||||
container.createEl("p", {
|
||||
cls: "template-desc-text",
|
||||
text: template.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private refreshForm() {
|
||||
this.contentEl.empty();
|
||||
this.createTemplateSelection(this.contentEl);
|
||||
this.createWorkflowForm(this.contentEl);
|
||||
this.createButtons(this.contentEl);
|
||||
}
|
||||
|
||||
private addQuickStage() {
|
||||
if (!this.workflow.stages) {
|
||||
this.workflow.stages = [];
|
||||
}
|
||||
|
||||
const stageName = `Stage ${this.workflow.stages.length + 1}`;
|
||||
const newStage: WorkflowStage = {
|
||||
id: this.generateIdFromName(stageName),
|
||||
name: stageName,
|
||||
type: "linear",
|
||||
};
|
||||
|
||||
this.workflow.stages.push(newStage);
|
||||
this.renderStagesPreview(this.contentEl.querySelector(".workflow-stages-preview") as HTMLElement);
|
||||
}
|
||||
|
||||
private generateIdFromName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.substring(0, 30);
|
||||
}
|
||||
|
||||
private handleSave() {
|
||||
if (!this.workflow.name || !this.workflow.id) {
|
||||
new Notice(t("Please provide a workflow name and ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.workflow.stages || this.workflow.stages.length === 0) {
|
||||
new Notice(t("Please add at least one stage to the workflow"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the workflow has all required properties
|
||||
const completeWorkflow: WorkflowDefinition = {
|
||||
id: this.workflow.id,
|
||||
name: this.workflow.name,
|
||||
description: this.workflow.description || "",
|
||||
stages: this.workflow.stages,
|
||||
metadata: this.workflow.metadata || {
|
||||
version: "1.0",
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
lastModified: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
this.onSave(completeWorkflow);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { ExtraButtonComponent, Menu } from "obsidian";
|
||||
import { ExtraButtonComponent, Menu, setIcon } from "obsidian";
|
||||
import { Component } from "obsidian";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { Task } from "../types/task";
|
||||
import { createTaskCheckbox } from "./task-view/details";
|
||||
import { getStatusText } from "./task-view/details";
|
||||
import { t } from "../translations/helper";
|
||||
import { getStatusIcon } from "../icon";
|
||||
|
||||
export class StatusComponent extends Component {
|
||||
constructor(
|
||||
|
|
@ -51,7 +52,7 @@ export class StatusComponent extends Component {
|
|||
cls:
|
||||
"status-option" +
|
||||
(status.text === this.task.status
|
||||
? " current-status"
|
||||
? " current"
|
||||
: ""),
|
||||
attr: {
|
||||
"aria-label": getStatusText(
|
||||
|
|
@ -61,13 +62,20 @@ export class StatusComponent extends Component {
|
|||
},
|
||||
});
|
||||
|
||||
// Create checkbox-like element for the status
|
||||
const checkbox = createTaskCheckbox(
|
||||
status.text,
|
||||
this.task,
|
||||
statusEl
|
||||
);
|
||||
this.registerDomEvent(checkbox, "click", (evt) => {
|
||||
// Create checkbox-like element or icon for the status
|
||||
let interactiveElement: HTMLElement = statusEl;
|
||||
if (this.plugin.settings.enableTaskGeniusIcons) {
|
||||
setIcon(interactiveElement, status.status);
|
||||
} else {
|
||||
// Create checkbox-like element for the status
|
||||
interactiveElement = createTaskCheckbox(
|
||||
status.text,
|
||||
this.task,
|
||||
statusEl
|
||||
);
|
||||
}
|
||||
|
||||
this.registerDomEvent(interactiveElement, "click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
if (status.text === this.getTaskStatus()) {
|
||||
|
|
@ -81,7 +89,7 @@ export class StatusComponent extends Component {
|
|||
|
||||
if (status.text === "x" && !this.task.completed) {
|
||||
options.completed = true;
|
||||
options.completedDate = new Date().getTime();
|
||||
options.metadata.completedDate = new Date().getTime();
|
||||
}
|
||||
|
||||
this.params.onTaskUpdate?.(this.task, options);
|
||||
|
|
@ -117,15 +125,42 @@ export class StatusComponent extends Component {
|
|||
// Create menu items from unique statuses
|
||||
for (const [status, mark] of uniqueStatuses) {
|
||||
menu.addItem((item) => {
|
||||
item.titleEl.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "status-option-checkbox",
|
||||
},
|
||||
(el) => {
|
||||
createTaskCheckbox(mark, this.task, el);
|
||||
}
|
||||
);
|
||||
// Map marks to their corresponding icon names
|
||||
const markToIcon: Record<string, string> = {
|
||||
" ": "notStarted", // Empty/space for not started
|
||||
"/": "inProgress", // Forward slash for in progress
|
||||
"x": "completed", // x for completed
|
||||
"-": "abandoned", // Dash for abandoned
|
||||
"?": "planned", // Question mark for planned
|
||||
">": "inProgress",
|
||||
"X": "completed",
|
||||
};
|
||||
|
||||
const iconName = markToIcon[mark];
|
||||
|
||||
if (this.plugin.settings.enableTaskGeniusIcons && iconName) {
|
||||
// Use icon in menu
|
||||
item.titleEl.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "status-option-icon",
|
||||
},
|
||||
(el) => {
|
||||
setIcon(el, iconName);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Use checkbox in menu
|
||||
item.titleEl.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "status-option-checkbox",
|
||||
},
|
||||
(el) => {
|
||||
createTaskCheckbox(mark, this.task, el);
|
||||
}
|
||||
);
|
||||
}
|
||||
item.titleEl.createEl("span", {
|
||||
cls: "status-option",
|
||||
text: status,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ interface ViewEventHandlers {
|
|||
newStatusMark: string
|
||||
) => Promise<void>;
|
||||
onEventContextMenu?: (ev: MouseEvent, event: CalendarEvent) => void;
|
||||
onTaskUpdate?: (originalTask: Task, updatedTask: Task) => Promise<void>;
|
||||
}
|
||||
|
||||
// 视图组件工厂
|
||||
|
|
@ -105,6 +106,7 @@ class ViewComponentFactory {
|
|||
onTaskSelected: handlers.onTaskSelected,
|
||||
onTaskCompleted: handlers.onTaskCompleted,
|
||||
onTaskContextMenu: handlers.onTaskContextMenu,
|
||||
onTaskUpdate: handlers.onTaskUpdate,
|
||||
});
|
||||
|
||||
case "table":
|
||||
|
|
|
|||
|
|
@ -323,6 +323,30 @@ export class ViewConfigModal extends Modal {
|
|||
this.checkForChanges();
|
||||
});
|
||||
});
|
||||
|
||||
// Add weekend hiding toggle for calendar view
|
||||
new Setting(contentEl)
|
||||
.setName(t("Hide weekends"))
|
||||
.setDesc(t("Hide weekend columns (Saturday and Sunday) in calendar views."))
|
||||
.addToggle((toggle) => {
|
||||
const currentValue = (this.viewConfig.specificConfig as CalendarSpecificConfig)?.hideWeekends ?? false;
|
||||
toggle.setValue(currentValue);
|
||||
toggle.onChange((value) => {
|
||||
if (
|
||||
!this.viewConfig.specificConfig ||
|
||||
this.viewConfig.specificConfig.viewType !== "calendar"
|
||||
) {
|
||||
this.viewConfig.specificConfig = {
|
||||
viewType: "calendar",
|
||||
firstDayOfWeek: undefined,
|
||||
hideWeekends: value,
|
||||
};
|
||||
} else {
|
||||
(this.viewConfig.specificConfig as CalendarSpecificConfig).hideWeekends = value;
|
||||
}
|
||||
this.checkForChanges();
|
||||
});
|
||||
});
|
||||
} else if (isKanbanView) {
|
||||
new Setting(contentEl)
|
||||
.setName(t("Group by"))
|
||||
|
|
@ -761,6 +785,30 @@ export class ViewConfigModal extends Modal {
|
|||
this.checkForChanges();
|
||||
});
|
||||
});
|
||||
|
||||
// Add weekend hiding toggle for forecast view
|
||||
new Setting(contentEl)
|
||||
.setName(t("Hide weekends"))
|
||||
.setDesc(t("Hide weekend columns (Saturday and Sunday) in forecast calendar."))
|
||||
.addToggle((toggle) => {
|
||||
const currentValue = (this.viewConfig.specificConfig as ForecastSpecificConfig)?.hideWeekends ?? false;
|
||||
toggle.setValue(currentValue);
|
||||
toggle.onChange((value) => {
|
||||
if (
|
||||
!this.viewConfig.specificConfig ||
|
||||
this.viewConfig.specificConfig.viewType !== "forecast"
|
||||
) {
|
||||
this.viewConfig.specificConfig = {
|
||||
viewType: "forecast",
|
||||
firstDayOfWeek: undefined,
|
||||
hideWeekends: value,
|
||||
};
|
||||
} else {
|
||||
(this.viewConfig.specificConfig as ForecastSpecificConfig).hideWeekends = value;
|
||||
}
|
||||
this.checkForChanges();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Two Column View specific config
|
||||
|
|
|
|||
269
src/components/WorkflowProgressIndicator.ts
Normal file
269
src/components/WorkflowProgressIndicator.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { Component, setIcon } from "obsidian";
|
||||
import { WorkflowDefinition, WorkflowStage } from "../common/setting-definition";
|
||||
import TaskProgressBarPlugin from "../index";
|
||||
import { t } from "../translations/helper";
|
||||
|
||||
/**
|
||||
* Workflow progress indicator component for visualizing workflow completion
|
||||
*/
|
||||
export class WorkflowProgressIndicator extends Component {
|
||||
private containerEl: HTMLElement;
|
||||
private plugin: TaskProgressBarPlugin;
|
||||
private workflow: WorkflowDefinition;
|
||||
private currentStageId: string;
|
||||
private completedStages: string[] = [];
|
||||
|
||||
constructor(
|
||||
containerEl: HTMLElement,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
workflow: WorkflowDefinition,
|
||||
currentStageId: string,
|
||||
completedStages: string[] = []
|
||||
) {
|
||||
super();
|
||||
this.containerEl = containerEl;
|
||||
this.plugin = plugin;
|
||||
this.workflow = workflow;
|
||||
this.currentStageId = currentStageId;
|
||||
this.completedStages = completedStages;
|
||||
}
|
||||
|
||||
onload() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
this.containerEl.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the progress indicator with new stage information
|
||||
*/
|
||||
updateProgress(currentStageId: string, completedStages: string[] = []) {
|
||||
this.currentStageId = currentStageId;
|
||||
this.completedStages = completedStages;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the workflow progress indicator
|
||||
*/
|
||||
private render() {
|
||||
this.containerEl.empty();
|
||||
this.containerEl.addClass("workflow-progress-indicator");
|
||||
|
||||
// Create header
|
||||
const header = this.containerEl.createDiv({ cls: "workflow-progress-header" });
|
||||
header.createSpan({ cls: "workflow-name", text: this.workflow.name });
|
||||
|
||||
const progressText = this.getProgressText();
|
||||
header.createSpan({ cls: "workflow-progress-text", text: progressText });
|
||||
|
||||
// Create progress bar
|
||||
this.createProgressBar();
|
||||
|
||||
// Create stage list
|
||||
this.createStageList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the visual progress bar
|
||||
*/
|
||||
private createProgressBar() {
|
||||
const progressContainer = this.containerEl.createDiv({ cls: "workflow-progress-bar-container" });
|
||||
const progressBar = progressContainer.createDiv({ cls: "workflow-progress-bar" });
|
||||
|
||||
const totalStages = this.workflow.stages.length;
|
||||
const completedCount = this.completedStages.length;
|
||||
const currentStageIndex = this.workflow.stages.findIndex(stage => stage.id === this.currentStageId);
|
||||
|
||||
// Calculate progress percentage
|
||||
let progressPercentage = 0;
|
||||
if (totalStages > 0) {
|
||||
// Count completed stages plus partial progress for current stage
|
||||
progressPercentage = (completedCount / totalStages) * 100;
|
||||
|
||||
// Add partial progress for current stage if it's not completed
|
||||
if (currentStageIndex >= 0 && !this.completedStages.includes(this.currentStageId)) {
|
||||
progressPercentage += (1 / totalStages) * 50; // 50% progress for current stage
|
||||
}
|
||||
}
|
||||
|
||||
const progressFill = progressBar.createDiv({ cls: "workflow-progress-fill" });
|
||||
progressFill.style.width = `${Math.min(progressPercentage, 100)}%`;
|
||||
|
||||
// Add progress percentage text
|
||||
progressContainer.createSpan({
|
||||
cls: "workflow-progress-percentage",
|
||||
text: `${Math.round(progressPercentage)}%`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the detailed stage list
|
||||
*/
|
||||
private createStageList() {
|
||||
const stageListContainer = this.containerEl.createDiv({ cls: "workflow-stage-list" });
|
||||
|
||||
this.workflow.stages.forEach((stage, index) => {
|
||||
const stageItem = stageListContainer.createDiv({ cls: "workflow-stage-item" });
|
||||
|
||||
// Determine stage status
|
||||
const isCompleted = this.completedStages.includes(stage.id);
|
||||
const isCurrent = stage.id === this.currentStageId;
|
||||
const isPending = !isCompleted && !isCurrent;
|
||||
|
||||
// Add status classes
|
||||
if (isCompleted) stageItem.addClass("completed");
|
||||
if (isCurrent) stageItem.addClass("current");
|
||||
if (isPending) stageItem.addClass("pending");
|
||||
|
||||
// Create stage icon
|
||||
const stageIcon = stageItem.createDiv({ cls: "workflow-stage-icon" });
|
||||
this.setStageIcon(stageIcon, stage, isCompleted, isCurrent);
|
||||
|
||||
// Create stage content
|
||||
const stageContent = stageItem.createDiv({ cls: "workflow-stage-content" });
|
||||
|
||||
const stageName = stageContent.createDiv({ cls: "workflow-stage-name" });
|
||||
stageName.textContent = stage.name;
|
||||
|
||||
// Add stage type indicator
|
||||
const stageType = stageContent.createDiv({ cls: "workflow-stage-type" });
|
||||
stageType.textContent = this.getStageTypeText(stage);
|
||||
|
||||
// Add substages if they exist and stage is current
|
||||
if (isCurrent && stage.subStages && stage.subStages.length > 0) {
|
||||
this.createSubStageList(stageContent, stage);
|
||||
}
|
||||
|
||||
// Add stage number
|
||||
const stageNumber = stageItem.createDiv({ cls: "workflow-stage-number" });
|
||||
stageNumber.textContent = (index + 1).toString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates substage list for cycle stages
|
||||
*/
|
||||
private createSubStageList(container: HTMLElement, stage: WorkflowStage) {
|
||||
if (!stage.subStages) return;
|
||||
|
||||
const subStageContainer = container.createDiv({ cls: "workflow-substage-container" });
|
||||
|
||||
stage.subStages.forEach((subStage) => {
|
||||
const subStageItem = subStageContainer.createDiv({ cls: "workflow-substage-item" });
|
||||
|
||||
const subStageIcon = subStageItem.createDiv({ cls: "workflow-substage-icon" });
|
||||
setIcon(subStageIcon, "circle");
|
||||
|
||||
const subStageName = subStageItem.createDiv({ cls: "workflow-substage-name" });
|
||||
subStageName.textContent = subStage.name;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the appropriate icon for a stage based on its status
|
||||
*/
|
||||
private setStageIcon(iconEl: HTMLElement, stage: WorkflowStage, isCompleted: boolean, isCurrent: boolean) {
|
||||
if (isCompleted) {
|
||||
setIcon(iconEl, "check-circle");
|
||||
iconEl.addClass("completed-icon");
|
||||
} else if (isCurrent) {
|
||||
if (stage.type === "cycle") {
|
||||
setIcon(iconEl, "rotate-cw");
|
||||
} else if (stage.type === "terminal") {
|
||||
setIcon(iconEl, "flag");
|
||||
} else {
|
||||
setIcon(iconEl, "play-circle");
|
||||
}
|
||||
iconEl.addClass("current-icon");
|
||||
} else {
|
||||
setIcon(iconEl, "circle");
|
||||
iconEl.addClass("pending-icon");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display text for stage type
|
||||
*/
|
||||
private getStageTypeText(stage: WorkflowStage): string {
|
||||
switch (stage.type) {
|
||||
case "cycle":
|
||||
return t("Repeatable");
|
||||
case "terminal":
|
||||
return t("Final");
|
||||
case "linear":
|
||||
default:
|
||||
return t("Sequential");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the progress text summary
|
||||
*/
|
||||
private getProgressText(): string {
|
||||
const totalStages = this.workflow.stages.length;
|
||||
const completedCount = this.completedStages.length;
|
||||
const currentStage = this.workflow.stages.find(stage => stage.id === this.currentStageId);
|
||||
|
||||
if (completedCount === totalStages) {
|
||||
return t("Completed");
|
||||
} else if (currentStage) {
|
||||
return t("Current: ") + currentStage.name;
|
||||
} else {
|
||||
return `${completedCount}/${totalStages} ${t("completed")}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to create and render a workflow progress indicator
|
||||
*/
|
||||
static create(
|
||||
containerEl: HTMLElement,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
workflow: WorkflowDefinition,
|
||||
currentStageId: string,
|
||||
completedStages: string[] = []
|
||||
): WorkflowProgressIndicator {
|
||||
const indicator = new WorkflowProgressIndicator(
|
||||
containerEl,
|
||||
plugin,
|
||||
workflow,
|
||||
currentStageId,
|
||||
completedStages
|
||||
);
|
||||
indicator.load();
|
||||
return indicator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates completed stages from a workflow task hierarchy
|
||||
*/
|
||||
static calculateCompletedStages(
|
||||
workflowTasks: Array<{ stage: string; completed: boolean }>,
|
||||
workflow: WorkflowDefinition
|
||||
): string[] {
|
||||
const completed: string[] = [];
|
||||
|
||||
// Simple heuristic: a stage is completed if all its tasks are completed
|
||||
const stageTaskCounts = new Map<string, { total: number; completed: number }>();
|
||||
|
||||
workflowTasks.forEach(task => {
|
||||
const current = stageTaskCounts.get(task.stage) || { total: 0, completed: 0 };
|
||||
current.total++;
|
||||
if (task.completed) {
|
||||
current.completed++;
|
||||
}
|
||||
stageTaskCounts.set(task.stage, current);
|
||||
});
|
||||
|
||||
stageTaskCounts.forEach((counts, stageId) => {
|
||||
if (counts.completed === counts.total && counts.total > 0) {
|
||||
completed.push(stageId);
|
||||
}
|
||||
});
|
||||
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@ import {
|
|||
setIcon,
|
||||
} from "obsidian";
|
||||
import { Task } from "../../types/task"; // Assuming Task type exists here
|
||||
import { IcsTask } from "../../types/ics";
|
||||
// Removed: import { renderCalendarEvent } from "./event";
|
||||
import "../../styles/calendar/view.css"; // Import the CSS file
|
||||
import "../../styles/calendar/event.css"; // Import the CSS file
|
||||
import "../../styles/calendar/badge.css"; // Import the badge CSS file
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
// Import view rendering functions
|
||||
|
|
@ -57,6 +59,10 @@ export class CalendarComponent extends Component {
|
|||
// Track the currently active view component
|
||||
private activeViewComponent: CalendarView | null = null;
|
||||
|
||||
// Performance optimization: Cache badge events by date
|
||||
private badgeEventsCache: Map<string, CalendarEvent[]> = new Map();
|
||||
private badgeEventsCacheVersion: number = 0;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin,
|
||||
|
|
@ -121,6 +127,8 @@ export class CalendarComponent extends Component {
|
|||
*/
|
||||
updateTasks(newTasks: Task[]) {
|
||||
this.tasks = newTasks;
|
||||
// Clear badge cache when tasks change
|
||||
this.invalidateBadgeEventsCache();
|
||||
this.processTasks();
|
||||
// Only update the currently active view
|
||||
if (this.activeViewComponent) {
|
||||
|
|
@ -187,6 +195,8 @@ export class CalendarComponent extends Component {
|
|||
*/
|
||||
public setTasks(tasks: Task[]) {
|
||||
this.tasks = tasks;
|
||||
// Clear badge cache when tasks change
|
||||
this.invalidateBadgeEventsCache();
|
||||
this.processTasks();
|
||||
this.render(); // Re-render header and update the view
|
||||
}
|
||||
|
|
@ -307,6 +317,8 @@ export class CalendarComponent extends Component {
|
|||
onDayHover: this.onDayHover,
|
||||
onEventContextMenu: this.onEventContextMenu,
|
||||
onEventComplete: this.onEventComplete,
|
||||
getBadgeEventsForDate:
|
||||
this.getBadgeEventsForDate.bind(this),
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
|
@ -325,6 +337,8 @@ export class CalendarComponent extends Component {
|
|||
onDayHover: this.onDayHover,
|
||||
onEventContextMenu: this.onEventContextMenu,
|
||||
onEventComplete: this.onEventComplete,
|
||||
getBadgeEventsForDate:
|
||||
this.getBadgeEventsForDate.bind(this),
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
|
@ -394,6 +408,8 @@ export class CalendarComponent extends Component {
|
|||
if (nextViewComponent) {
|
||||
this.activeViewComponent = nextViewComponent;
|
||||
this.addChild(this.activeViewComponent); // Load and attach the new component
|
||||
// Pre-compute badge events for better performance
|
||||
this.precomputeBadgeEventsForCurrentView();
|
||||
// Update the newly activated view with current data
|
||||
this.activeViewComponent.updateEvents(this.events);
|
||||
} else {
|
||||
|
|
@ -401,6 +417,8 @@ export class CalendarComponent extends Component {
|
|||
}
|
||||
} else if (this.activeViewComponent) {
|
||||
// If the view is the same, just update it with potentially new date/events
|
||||
// Pre-compute badge events for better performance
|
||||
this.precomputeBadgeEventsForCurrentView();
|
||||
this.activeViewComponent.updateEvents(this.events);
|
||||
}
|
||||
|
||||
|
|
@ -429,45 +447,73 @@ export class CalendarComponent extends Component {
|
|||
/**
|
||||
* Processes the raw tasks into calendar events.
|
||||
*/
|
||||
private processTasks() {
|
||||
private async processTasks() {
|
||||
this.events = [];
|
||||
// Clear badge cache when processing tasks
|
||||
this.invalidateBadgeEventsCache();
|
||||
const primaryDateField = "dueDate"; // TODO: Make this configurable via settings
|
||||
|
||||
// Process tasks
|
||||
this.tasks.forEach((task) => {
|
||||
// Check if this is an ICS task with badge showType
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null; // Type assertion for IcsTask
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
// Skip ICS tasks with badge showType - they will be handled separately
|
||||
if (isIcsTask && showAsBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the date to use based on priority (dueDate > scheduledDate > startDate)
|
||||
// This logic might need refinement based on exact requirements in PRD 4.2
|
||||
let eventDate: number | null = null;
|
||||
let isAllDay = true; // Assume tasks are all-day unless time info exists
|
||||
|
||||
// Use the first available date field based on preference.
|
||||
// The PRD mentions using dueDate primarily, with an option for scheduled/start.
|
||||
// Let's stick to dueDate for now as primary.
|
||||
if (task[primaryDateField]) {
|
||||
eventDate = task[primaryDateField];
|
||||
} else if (task.scheduledDate) {
|
||||
eventDate = task.scheduledDate;
|
||||
} else if (task.startDate) {
|
||||
eventDate = task.startDate;
|
||||
// For ICS tasks, use the ICS event dates directly
|
||||
if (isIcsTask && icsTask?.icsEvent) {
|
||||
eventDate = icsTask.icsEvent.dtstart.getTime();
|
||||
isAllDay = icsTask.icsEvent.allDay;
|
||||
} else {
|
||||
// Use the first available date field based on preference.
|
||||
// The PRD mentions using dueDate primarily, with an option for scheduled/start.
|
||||
// Let's stick to dueDate for now as primary.
|
||||
if (task.metadata[primaryDateField]) {
|
||||
eventDate = task.metadata[primaryDateField];
|
||||
} else if (task.metadata.scheduledDate) {
|
||||
eventDate = task.metadata.scheduledDate;
|
||||
} else if (task.metadata.startDate) {
|
||||
eventDate = task.metadata.startDate;
|
||||
}
|
||||
}
|
||||
// We could add completedDate here if we want to show completed tasks based on completion time
|
||||
|
||||
if (eventDate) {
|
||||
const startMoment = moment(eventDate);
|
||||
// Try to parse time if available in the task string or metadata (complex)
|
||||
// For now, assume all tasks are all-day events on their primary date
|
||||
const start = startMoment.startOf("day").toDate(); // Represent as start of the day
|
||||
// For ICS events, preserve the original time if not all-day
|
||||
const start = isAllDay
|
||||
? startMoment.startOf("day").toDate()
|
||||
: startMoment.toDate();
|
||||
|
||||
// Handle multi-day? PRD mentions if startDate and dueDate are available.
|
||||
let end: Date | undefined = undefined;
|
||||
let effectiveStart = start; // Use the primary date as start by default
|
||||
if (
|
||||
task.startDate &&
|
||||
task.dueDate &&
|
||||
task.startDate !== task.dueDate
|
||||
|
||||
if (isIcsTask && icsTask?.icsEvent?.dtend) {
|
||||
// For ICS events, use the end date from the event
|
||||
end = icsTask.icsEvent.dtend;
|
||||
} else if (
|
||||
task.metadata.startDate &&
|
||||
task.metadata.dueDate &&
|
||||
task.metadata.startDate !== task.metadata.dueDate
|
||||
) {
|
||||
// Ensure start is actually before due date
|
||||
const sMoment = moment(task.startDate).startOf("day");
|
||||
const dMoment = moment(task.dueDate).startOf("day");
|
||||
const sMoment = moment(task.metadata.startDate).startOf(
|
||||
"day"
|
||||
);
|
||||
const dMoment = moment(task.metadata.dueDate).startOf(
|
||||
"day"
|
||||
);
|
||||
if (sMoment.isBefore(dMoment)) {
|
||||
// FullCalendar and similar often expect the 'end' date to be exclusive
|
||||
// for all-day events. So an event ending on the 15th would have end=16th.
|
||||
|
|
@ -477,14 +523,21 @@ export class CalendarComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine color for the event
|
||||
let eventColor: string | undefined;
|
||||
if (isIcsTask && icsTask?.icsEvent?.source?.color) {
|
||||
eventColor = icsTask.icsEvent.source.color;
|
||||
} else {
|
||||
eventColor = task.completed ? "grey" : undefined;
|
||||
}
|
||||
|
||||
this.events.push({
|
||||
...task, // Spread all properties from the original task
|
||||
title: task.content, // Use task content as title by default
|
||||
start: effectiveStart,
|
||||
end: end, // Add end date if calculated
|
||||
allDay: isAllDay,
|
||||
// TODO: Add color based on status, priority, or project?
|
||||
color: task.completed ? "grey" : undefined, // Simple example
|
||||
color: eventColor,
|
||||
});
|
||||
}
|
||||
// Else: Task has no relevant date, ignore for now (PRD: maybe "unscheduled" panel)
|
||||
|
|
@ -494,10 +547,230 @@ export class CalendarComponent extends Component {
|
|||
this.events.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
console.log(
|
||||
`Processed ${this.events.length} events from ${this.tasks.length} tasks.`
|
||||
`Processed ${this.events.length} events from ${this.tasks.length} tasks (including ICS events as tasks).`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the badge events cache
|
||||
*/
|
||||
private invalidateBadgeEventsCache(): void {
|
||||
this.badgeEventsCache.clear();
|
||||
this.badgeEventsCacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compute badge events for a date range to optimize performance
|
||||
* This replaces the per-date filtering with a single pass through all tasks
|
||||
*/
|
||||
private precomputeBadgeEventsForRange(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): void {
|
||||
// Convert dates to YYYY-MM-DD format for consistent comparison
|
||||
const formatDateKey = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Clear existing cache for the range
|
||||
const startKey = formatDateKey(startDate);
|
||||
const endKey = formatDateKey(endDate);
|
||||
|
||||
// Initialize cache entries for the date range
|
||||
const currentDate = new Date(startDate);
|
||||
while (currentDate <= endDate) {
|
||||
const dateKey = formatDateKey(currentDate);
|
||||
this.badgeEventsCache.set(dateKey, []);
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Single pass through all tasks to populate cache
|
||||
this.tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
// Use native Date operations instead of moment for better performance
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
// Normalize to start of day for comparison
|
||||
const eventDateNormalized = new Date(
|
||||
eventDate.getFullYear(),
|
||||
eventDate.getMonth(),
|
||||
eventDate.getDate()
|
||||
);
|
||||
const eventDateKey = formatDateKey(eventDateNormalized);
|
||||
|
||||
// Check if the event is within our cached range
|
||||
if (this.badgeEventsCache.has(eventDateKey)) {
|
||||
// Convert the task to a CalendarEvent format for consistency
|
||||
const calendarEvent: CalendarEvent = {
|
||||
...task,
|
||||
title: task.content,
|
||||
start: icsTask.icsEvent.dtstart,
|
||||
end: icsTask.icsEvent.dtend,
|
||||
allDay: icsTask.icsEvent.allDay,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
};
|
||||
|
||||
const existingEvents =
|
||||
this.badgeEventsCache.get(eventDateKey) || [];
|
||||
existingEvents.push(calendarEvent);
|
||||
this.badgeEventsCache.set(eventDateKey, existingEvents);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Pre-computed badge events for range ${startKey} to ${endKey}. Cache size: ${this.badgeEventsCache.size}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge events for a specific date (optimized version)
|
||||
* These are ICS events that should be displayed as badges (count) rather than full events
|
||||
*/
|
||||
public getBadgeEventsForDate(date: Date): CalendarEvent[] {
|
||||
// Use native Date operations for better performance
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
const normalizedDate = new Date(year, month, day);
|
||||
const dateKey = `${year}-${String(month + 1).padStart(2, "0")}-${String(
|
||||
day
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
// Check if we have cached data for this date
|
||||
if (this.badgeEventsCache.has(dateKey)) {
|
||||
const cachedEvents = this.badgeEventsCache.get(dateKey) || [];
|
||||
return cachedEvents;
|
||||
}
|
||||
|
||||
const badgeEventsForDate: CalendarEvent[] = [];
|
||||
|
||||
this.tasks.forEach((task) => {
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as IcsTask) : null;
|
||||
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
|
||||
|
||||
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
|
||||
// Use native Date operations instead of moment for better performance
|
||||
const eventDate = new Date(icsTask.icsEvent.dtstart);
|
||||
const eventYear = eventDate.getFullYear();
|
||||
const eventMonth = eventDate.getMonth();
|
||||
const eventDay = eventDate.getDate();
|
||||
|
||||
// Check if the event is on the target date using native comparison
|
||||
if (
|
||||
eventYear === year &&
|
||||
eventMonth === month &&
|
||||
eventDay === day
|
||||
) {
|
||||
// Convert the task to a CalendarEvent format for consistency
|
||||
const calendarEvent: CalendarEvent = {
|
||||
...task,
|
||||
title: task.content,
|
||||
start: icsTask.icsEvent.dtstart,
|
||||
end: icsTask.icsEvent.dtend,
|
||||
allDay: icsTask.icsEvent.allDay,
|
||||
color: icsTask.icsEvent.source.color,
|
||||
};
|
||||
badgeEventsForDate.push(calendarEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result for future use
|
||||
this.badgeEventsCache.set(dateKey, badgeEventsForDate);
|
||||
|
||||
return badgeEventsForDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compute badge events for the current view's date range
|
||||
* This should be called when the view changes or data updates
|
||||
*/
|
||||
public precomputeBadgeEventsForCurrentView(): void {
|
||||
if (!this.activeViewComponent) return;
|
||||
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
switch (this.currentViewMode) {
|
||||
case "month":
|
||||
// For month view, compute for the entire grid (including previous/next month days)
|
||||
const startOfMonth = this.currentDate.clone().startOf("month");
|
||||
const endOfMonth = this.currentDate.clone().endOf("month");
|
||||
|
||||
// Get first day of week setting
|
||||
const viewConfig = this.plugin.settings.viewConfiguration.find(
|
||||
(v) => v.id === this.viewId
|
||||
)?.specificConfig as any; // Use any for now to avoid import complexity
|
||||
const firstDayOfWeek = viewConfig?.firstDayOfWeek ?? 0;
|
||||
|
||||
const gridStart = startOfMonth
|
||||
.clone()
|
||||
.weekday(firstDayOfWeek - 7);
|
||||
let gridEnd = endOfMonth.clone().weekday(firstDayOfWeek + 6);
|
||||
|
||||
// Ensure at least 42 days (6 weeks)
|
||||
if (gridEnd.diff(gridStart, "days") + 1 < 42) {
|
||||
const daysToAdd =
|
||||
42 - (gridEnd.diff(gridStart, "days") + 1);
|
||||
gridEnd.add(daysToAdd, "days");
|
||||
}
|
||||
|
||||
startDate = gridStart.toDate();
|
||||
endDate = gridEnd.toDate();
|
||||
break;
|
||||
|
||||
case "week":
|
||||
const startOfWeek = this.currentDate.clone().startOf("week");
|
||||
const endOfWeek = this.currentDate.clone().endOf("week");
|
||||
startDate = startOfWeek.toDate();
|
||||
endDate = endOfWeek.toDate();
|
||||
break;
|
||||
|
||||
case "day":
|
||||
startDate = this.currentDate.clone().startOf("day").toDate();
|
||||
endDate = this.currentDate.clone().endOf("day").toDate();
|
||||
break;
|
||||
|
||||
case "year":
|
||||
const startOfYear = this.currentDate.clone().startOf("year");
|
||||
const endOfYear = this.currentDate.clone().endOf("year");
|
||||
startDate = startOfYear.toDate();
|
||||
endDate = endOfYear.toDate();
|
||||
break;
|
||||
|
||||
default:
|
||||
// For agenda and other views, use a reasonable default range
|
||||
startDate = this.currentDate.clone().startOf("day").toDate();
|
||||
endDate = this.currentDate.clone().add(30, "days").toDate();
|
||||
}
|
||||
|
||||
this.precomputeBadgeEventsForRange(startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ICS priority to task priority
|
||||
*/
|
||||
private mapIcsPriorityToTaskPriority(
|
||||
icsPriority?: number
|
||||
): number | undefined {
|
||||
if (icsPriority === undefined) return undefined;
|
||||
|
||||
// ICS priority: 0 (undefined), 1-4 (high), 5 (normal), 6-9 (low)
|
||||
// Task priority: 1 (highest), 2 (high), 3 (medium), 4 (low), 5 (lowest)
|
||||
if (icsPriority >= 1 && icsPriority <= 4) return 1; // High
|
||||
if (icsPriority === 5) return 3; // Medium
|
||||
if (icsPriority >= 6 && icsPriority <= 9) return 5; // Low
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Utility Methods ---
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -62,12 +62,13 @@ export class CalendarEventComponent extends Component {
|
|||
cls: ["calendar-event", `calendar-event-${this.viewType}`],
|
||||
});
|
||||
|
||||
if (this.event.project) {
|
||||
this.eventEl.dataset.projectId = this.event.project;
|
||||
if (this.event.metadata.project) {
|
||||
this.eventEl.dataset.projectId = this.event.metadata.project;
|
||||
}
|
||||
|
||||
if (this.event.priority) {
|
||||
this.eventEl.dataset.priority = this.event.priority.toString();
|
||||
if (this.event.metadata.priority) {
|
||||
this.eventEl.dataset.priority =
|
||||
this.event.metadata.priority.toString();
|
||||
}
|
||||
|
||||
if (this.event.status) {
|
||||
|
|
@ -123,14 +124,14 @@ export class CalendarEventComponent extends Component {
|
|||
`${clearAllMarks(this.event.title) || "(No title)"}\nStatus: ${
|
||||
this.event.status
|
||||
}${
|
||||
this.event.dueDate
|
||||
? `\nDue: ${moment(this.event.dueDate).format(
|
||||
this.event.metadata.dueDate
|
||||
? `\nDue: ${moment(this.event.metadata.dueDate).format(
|
||||
"YYYY-MM-DD"
|
||||
)}`
|
||||
: ""
|
||||
}${
|
||||
this.event.startDate
|
||||
? `\nStart: ${moment(this.event.startDate).format(
|
||||
this.event.metadata.startDate
|
||||
? `\nStart: ${moment(this.event.metadata.startDate).format(
|
||||
"YYYY-MM-DD"
|
||||
)}`
|
||||
: ""
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface EventMap {
|
|||
// Combine event handlers into a single options object, making them optional
|
||||
export interface CalendarViewOptions extends Partial<EventMap> {
|
||||
// Add other common view options here if needed
|
||||
getBadgeEventsForDate?: (date: Date) => CalendarEvent[];
|
||||
}
|
||||
|
||||
export abstract class CalendarViewComponent extends Component {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@ import TaskProgressBarPlugin from "../../../index"; // Import plugin type for se
|
|||
import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; // Import base class and options type
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
/**
|
||||
* Utility function to parse date string (YYYY-MM-DD) to Date object
|
||||
* Optimized for performance to replace moment.js usage
|
||||
*/
|
||||
function parseDateString(dateStr: string): Date {
|
||||
const dateParts = dateStr.split("-");
|
||||
const year = parseInt(dateParts[0], 10);
|
||||
const month = parseInt(dateParts[1], 10) - 1; // Month is 0-indexed in Date
|
||||
const day = parseInt(dateParts[2], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the month view grid as a component.
|
||||
*/
|
||||
|
|
@ -34,11 +46,12 @@ export class MonthView extends CalendarViewComponent {
|
|||
}
|
||||
|
||||
render(): void {
|
||||
// Get view settings, including the first day of the week override
|
||||
// Get view settings, including the first day of the week override and weekend hiding
|
||||
const viewConfig = this.plugin.settings.viewConfiguration.find(
|
||||
(v) => v.id === this.currentViewId
|
||||
)?.specificConfig as CalendarSpecificConfig; // Assuming 'calendar' view for settings lookup, adjust if needed
|
||||
const firstDayOfWeekSetting = viewConfig?.firstDayOfWeek;
|
||||
const hideWeekends = viewConfig?.hideWeekends ?? false;
|
||||
// Default to Sunday (0) if the setting is undefined, following 0=Sun, 1=Mon, ..., 6=Sat
|
||||
const effectiveFirstDay =
|
||||
firstDayOfWeekSetting === undefined ? 0 : firstDayOfWeekSetting;
|
||||
|
|
@ -51,17 +64,50 @@ export class MonthView extends CalendarViewComponent {
|
|||
// Calculate grid end based on the week containing the end of the month, adjusted for the effective first day
|
||||
let gridEnd = endOfMonth.clone().weekday(effectiveFirstDay + 6); // moment handles wrapping correctly
|
||||
|
||||
// Ensure grid covers at least 6 weeks (42 days) for consistent layout
|
||||
// This logic should still work fine with custom start/end days
|
||||
if (gridEnd.diff(gridStart, "days") + 1 < 42) {
|
||||
// Add full weeks until at least 42 days are covered
|
||||
const daysToAdd = 42 - (gridEnd.diff(gridStart, "days") + 1);
|
||||
gridEnd.add(daysToAdd, "days");
|
||||
// Adjust grid coverage based on whether weekends are hidden
|
||||
if (hideWeekends) {
|
||||
// When weekends are hidden, we need fewer days to fill the grid
|
||||
// Calculate how many work days we need (approximately 6 weeks * 5 work days = 30 days)
|
||||
let workDaysCount = 0;
|
||||
let tempIter = gridStart.clone();
|
||||
|
||||
// Count existing work days in the current range
|
||||
while (tempIter.isSameOrBefore(gridEnd, "day")) {
|
||||
const isWeekend = tempIter.day() === 0 || tempIter.day() === 6;
|
||||
if (!isWeekend) {
|
||||
workDaysCount++;
|
||||
}
|
||||
tempIter.add(1, "day");
|
||||
}
|
||||
|
||||
// Ensure we have at least 30 work days (6 weeks * 5 days) for consistent layout
|
||||
while (workDaysCount < 30) {
|
||||
gridEnd.add(1, "day");
|
||||
const isWeekend = gridEnd.day() === 0 || gridEnd.day() === 6;
|
||||
if (!isWeekend) {
|
||||
workDaysCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Original logic for when weekends are shown
|
||||
// Ensure grid covers at least 6 weeks (42 days) for consistent layout
|
||||
if (gridEnd.diff(gridStart, "days") + 1 < 42) {
|
||||
// Add full weeks until at least 42 days are covered
|
||||
const daysToAdd = 42 - (gridEnd.diff(gridStart, "days") + 1);
|
||||
gridEnd.add(daysToAdd, "days");
|
||||
}
|
||||
}
|
||||
|
||||
this.containerEl.empty();
|
||||
this.containerEl.addClass("view-month"); // Add class for styling
|
||||
|
||||
// Add hide-weekends class if weekend hiding is enabled
|
||||
if (hideWeekends) {
|
||||
this.containerEl.addClass("hide-weekends");
|
||||
} else {
|
||||
this.containerEl.removeClass("hide-weekends");
|
||||
}
|
||||
|
||||
// 2. Add weekday headers, rotated according to effective first day
|
||||
const headerRow = this.containerEl.createDiv("calendar-weekday-header");
|
||||
const weekdays = moment.weekdaysShort(true); // Gets locale-aware short weekdays
|
||||
|
|
@ -69,7 +115,17 @@ export class MonthView extends CalendarViewComponent {
|
|||
...weekdays.slice(effectiveFirstDay),
|
||||
...weekdays.slice(0, effectiveFirstDay),
|
||||
];
|
||||
rotatedWeekdays.forEach((day) => {
|
||||
|
||||
// Filter out weekends if hideWeekends is enabled
|
||||
const filteredWeekdays = hideWeekends
|
||||
? rotatedWeekdays.filter((_, index) => {
|
||||
// Calculate the actual day of week for this header position
|
||||
const dayOfWeek = (effectiveFirstDay + index) % 7;
|
||||
return dayOfWeek !== 0 && dayOfWeek !== 6; // Exclude Sunday (0) and Saturday (6)
|
||||
})
|
||||
: rotatedWeekdays;
|
||||
|
||||
filteredWeekdays.forEach((day) => {
|
||||
const weekdayEl = headerRow.createDiv("calendar-weekday");
|
||||
weekdayEl.textContent = day;
|
||||
});
|
||||
|
|
@ -80,6 +136,14 @@ export class MonthView extends CalendarViewComponent {
|
|||
let currentDayIter = gridStart.clone();
|
||||
|
||||
while (currentDayIter.isSameOrBefore(gridEnd, "day")) {
|
||||
const isWeekend = currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
|
||||
|
||||
// Skip weekend days if hideWeekends is enabled
|
||||
if (hideWeekends && isWeekend) {
|
||||
currentDayIter.add(1, "day");
|
||||
continue;
|
||||
}
|
||||
|
||||
const cell = gridContainer.createEl("div", {
|
||||
cls: "calendar-day-cell",
|
||||
attr: {
|
||||
|
|
@ -89,8 +153,9 @@ export class MonthView extends CalendarViewComponent {
|
|||
const dateStr = currentDayIter.format("YYYY-MM-DD");
|
||||
dayCells[dateStr] = cell;
|
||||
|
||||
const headerEl = cell.createDiv("calendar-day-header");
|
||||
// Add day number
|
||||
const dayNumberEl = cell.createDiv("calendar-day-number");
|
||||
const dayNumberEl = headerEl.createDiv("calendar-day-number");
|
||||
dayNumberEl.textContent = currentDayIter.format("D");
|
||||
|
||||
// Add styling classes
|
||||
|
|
@ -100,9 +165,9 @@ export class MonthView extends CalendarViewComponent {
|
|||
if (currentDayIter.isSame(moment(), "day")) {
|
||||
cell.addClass("is-today");
|
||||
}
|
||||
// Weekend check might need adjustment depending on visual definition of weekend with custom start day
|
||||
if (currentDayIter.day() === 0 || currentDayIter.day() === 6) {
|
||||
// Sunday or Saturday
|
||||
// Note: We don't add is-weekend class when hideWeekends is enabled
|
||||
// because weekend cells are not created at all
|
||||
if (!hideWeekends && isWeekend) {
|
||||
cell.addClass("is-weekend");
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +223,40 @@ export class MonthView extends CalendarViewComponent {
|
|||
// --- End of simplified logic ---
|
||||
});
|
||||
|
||||
// 5. Render badges for ICS events with badge showType
|
||||
Object.keys(dayCells).forEach((dateStr) => {
|
||||
const cell = dayCells[dateStr];
|
||||
// Use optimized date parsing for better performance
|
||||
const date = parseDateString(dateStr);
|
||||
|
||||
const badgeEvents =
|
||||
this.options.getBadgeEventsForDate?.(date) || [];
|
||||
|
||||
if (badgeEvents.length > 0) {
|
||||
const headerEl = cell.querySelector(
|
||||
".calendar-day-header"
|
||||
) as HTMLElement;
|
||||
const badgesContainer = headerEl.createDiv(
|
||||
"calendar-badges-container"
|
||||
);
|
||||
if (badgesContainer) {
|
||||
badgeEvents.forEach((badgeEvent) => {
|
||||
const badgeEl = badgesContainer.createEl("div", {
|
||||
cls: "calendar-badge",
|
||||
});
|
||||
|
||||
// Add color styling if available
|
||||
if (badgeEvent.color) {
|
||||
badgeEl.style.backgroundColor = badgeEvent.color;
|
||||
}
|
||||
|
||||
// Add count text
|
||||
badgeEl.textContent = badgeEvent.content;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Rendered Month View component from ${gridStart.format(
|
||||
"YYYY-MM-DD"
|
||||
|
|
@ -172,9 +271,11 @@ export class MonthView extends CalendarViewComponent {
|
|||
const dateStr = target
|
||||
.closest(".calendar-day-cell")
|
||||
?.getAttribute("data-date");
|
||||
if (this.options.onDayClick) {
|
||||
if (this.options.onDayClick && dateStr) {
|
||||
console.log("Day number clicked:", dateStr);
|
||||
this.options.onDayClick(ev, moment(dateStr).valueOf(), {
|
||||
// Use optimized date parsing for better performance
|
||||
const date = parseDateString(dateStr);
|
||||
this.options.onDayClick(ev, date.getTime(), {
|
||||
behavior: "open-task-view",
|
||||
});
|
||||
}
|
||||
|
|
@ -185,8 +286,10 @@ export class MonthView extends CalendarViewComponent {
|
|||
const dateStr = target
|
||||
.closest(".calendar-day-cell")
|
||||
?.getAttribute("data-date");
|
||||
if (this.options.onDayClick) {
|
||||
this.options.onDayClick(ev, moment(dateStr).valueOf(), {
|
||||
if (this.options.onDayClick && dateStr) {
|
||||
// Use optimized date parsing for better performance
|
||||
const date = parseDateString(dateStr);
|
||||
this.options.onDayClick(ev, date.getTime(), {
|
||||
behavior: "open-quick-capture",
|
||||
});
|
||||
}
|
||||
|
|
@ -218,8 +321,10 @@ export class MonthView extends CalendarViewComponent {
|
|||
const dateStr = target
|
||||
.closest(".calendar-day-cell")
|
||||
?.getAttribute("data-date");
|
||||
if (this.options.onDayHover) {
|
||||
this.options.onDayHover(ev, moment(dateStr).valueOf());
|
||||
if (this.options.onDayHover && dateStr) {
|
||||
// Use optimized date parsing for better performance
|
||||
const date = parseDateString(dateStr);
|
||||
this.options.onDayHover(ev, date.getTime());
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
|
@ -227,23 +332,27 @@ export class MonthView extends CalendarViewComponent {
|
|||
/**
|
||||
* Initialize drag and drop functionality for calendar events
|
||||
*/
|
||||
private initializeDragAndDrop(dayCells: { [key: string]: HTMLElement }): void {
|
||||
private initializeDragAndDrop(dayCells: {
|
||||
[key: string]: HTMLElement;
|
||||
}): void {
|
||||
// Clean up existing sortable instances
|
||||
this.sortableInstances.forEach(instance => instance.destroy());
|
||||
this.sortableInstances.forEach((instance) => instance.destroy());
|
||||
this.sortableInstances = [];
|
||||
|
||||
// Initialize sortable for each day's events container
|
||||
Object.entries(dayCells).forEach(([dateStr, dayCell]) => {
|
||||
const eventsContainer = dayCell.querySelector('.calendar-events-container') as HTMLElement;
|
||||
const eventsContainer = dayCell.querySelector(
|
||||
".calendar-events-container"
|
||||
) as HTMLElement;
|
||||
if (eventsContainer) {
|
||||
const sortableInstance = Sortable.create(eventsContainer, {
|
||||
group: 'calendar-events',
|
||||
group: "calendar-events",
|
||||
animation: 150,
|
||||
ghostClass: 'calendar-event-ghost',
|
||||
dragClass: 'calendar-event-dragging',
|
||||
ghostClass: "calendar-event-ghost",
|
||||
dragClass: "calendar-event-dragging",
|
||||
onEnd: (event) => {
|
||||
this.handleDragEnd(event, dateStr);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.sortableInstances.push(sortableInstance);
|
||||
}
|
||||
|
|
@ -253,25 +362,30 @@ export class MonthView extends CalendarViewComponent {
|
|||
/**
|
||||
* Handle drag end event to update task dates
|
||||
*/
|
||||
private async handleDragEnd(event: Sortable.SortableEvent, originalDateStr: string): Promise<void> {
|
||||
private async handleDragEnd(
|
||||
event: Sortable.SortableEvent,
|
||||
originalDateStr: string
|
||||
): Promise<void> {
|
||||
const eventEl = event.item;
|
||||
const eventId = eventEl.dataset.eventId;
|
||||
const targetContainer = event.to;
|
||||
const targetDateCell = targetContainer.closest('.calendar-day-cell');
|
||||
const targetDateCell = targetContainer.closest(".calendar-day-cell");
|
||||
|
||||
if (!eventId || !targetDateCell) {
|
||||
console.warn('Could not determine event ID or target date for drag operation');
|
||||
console.warn(
|
||||
"Could not determine event ID or target date for drag operation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDateStr = targetDateCell.getAttribute('data-date');
|
||||
const targetDateStr = targetDateCell.getAttribute("data-date");
|
||||
if (!targetDateStr || targetDateStr === originalDateStr) {
|
||||
// No date change, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the calendar event
|
||||
const calendarEvent = this.events.find(e => e.id === eventId);
|
||||
const calendarEvent = this.events.find((e) => e.id === eventId);
|
||||
if (!calendarEvent) {
|
||||
console.warn(`Calendar event with ID ${eventId} not found`);
|
||||
return;
|
||||
|
|
@ -279,9 +393,11 @@ export class MonthView extends CalendarViewComponent {
|
|||
|
||||
try {
|
||||
await this.updateTaskDate(calendarEvent, targetDateStr);
|
||||
console.log(`Task ${eventId} moved from ${originalDateStr} to ${targetDateStr}`);
|
||||
console.log(
|
||||
`Task ${eventId} moved from ${originalDateStr} to ${targetDateStr}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task date:', error);
|
||||
console.error("Failed to update task date:", error);
|
||||
// Revert the visual change by re-rendering
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -290,27 +406,32 @@ export class MonthView extends CalendarViewComponent {
|
|||
/**
|
||||
* Update task date based on the target date
|
||||
*/
|
||||
private async updateTaskDate(calendarEvent: CalendarEvent, targetDateStr: string): Promise<void> {
|
||||
const targetDate = moment(targetDateStr).valueOf();
|
||||
private async updateTaskDate(
|
||||
calendarEvent: CalendarEvent,
|
||||
targetDateStr: string
|
||||
): Promise<void> {
|
||||
// Use optimized date parsing for better performance
|
||||
const targetDate = parseDateString(targetDateStr).getTime();
|
||||
|
||||
const taskManager = this.plugin.taskManager;
|
||||
|
||||
if (!taskManager) {
|
||||
throw new Error('Task manager not available');
|
||||
throw new Error("Task manager not available");
|
||||
}
|
||||
|
||||
// Create updated task with new date
|
||||
const updatedTask = { ...calendarEvent };
|
||||
|
||||
// Determine which date field to update based on what the task currently has
|
||||
if (calendarEvent.dueDate) {
|
||||
updatedTask.dueDate = targetDate;
|
||||
} else if (calendarEvent.scheduledDate) {
|
||||
updatedTask.scheduledDate = targetDate;
|
||||
} else if (calendarEvent.startDate) {
|
||||
updatedTask.startDate = targetDate;
|
||||
if (calendarEvent.metadata.dueDate) {
|
||||
updatedTask.metadata.dueDate = targetDate;
|
||||
} else if (calendarEvent.metadata.scheduledDate) {
|
||||
updatedTask.metadata.scheduledDate = targetDate;
|
||||
} else if (calendarEvent.metadata.startDate) {
|
||||
updatedTask.metadata.startDate = targetDate;
|
||||
} else {
|
||||
// Default to due date if no date is set
|
||||
updatedTask.dueDate = targetDate;
|
||||
updatedTask.metadata.dueDate = targetDate;
|
||||
}
|
||||
|
||||
// Update the task
|
||||
|
|
@ -321,7 +442,7 @@ export class MonthView extends CalendarViewComponent {
|
|||
* Clean up sortable instances when component is destroyed
|
||||
*/
|
||||
onunload(): void {
|
||||
this.sortableInstances.forEach(instance => instance.destroy());
|
||||
this.sortableInstances.forEach((instance) => instance.destroy());
|
||||
this.sortableInstances = [];
|
||||
super.onunload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class WeekView extends CalendarViewComponent {
|
|||
}
|
||||
|
||||
render(): void {
|
||||
// Get view settings, including the first day of the week override
|
||||
// Get view settings, including the first day of the week override and weekend hiding
|
||||
const viewConfig = getViewSettingOrDefault(
|
||||
this.plugin,
|
||||
this.currentViewId
|
||||
|
|
@ -47,6 +47,7 @@ export class WeekView extends CalendarViewComponent {
|
|||
const firstDayOfWeekSetting = (
|
||||
viewConfig.specificConfig as CalendarSpecificConfig
|
||||
).firstDayOfWeek;
|
||||
const hideWeekends = (viewConfig.specificConfig as CalendarSpecificConfig)?.hideWeekends ?? false;
|
||||
// Default to Sunday (0) if the setting is undefined, following 0=Sun, 1=Mon, ..., 6=Sat
|
||||
const effectiveFirstDay =
|
||||
firstDayOfWeekSetting === undefined ? 0 : firstDayOfWeekSetting;
|
||||
|
|
@ -58,6 +59,13 @@ export class WeekView extends CalendarViewComponent {
|
|||
this.containerEl.empty();
|
||||
this.containerEl.addClass("view-week");
|
||||
|
||||
// Add hide-weekends class if weekend hiding is enabled
|
||||
if (hideWeekends) {
|
||||
this.containerEl.addClass("hide-weekends");
|
||||
} else {
|
||||
this.containerEl.removeClass("hide-weekends");
|
||||
}
|
||||
|
||||
// 1. Render Header Row (Days of the week + Dates)
|
||||
const headerRow = this.containerEl.createDiv("calendar-week-header");
|
||||
const dayHeaderCells: { [key: string]: HTMLElement } = {};
|
||||
|
|
@ -69,14 +77,32 @@ export class WeekView extends CalendarViewComponent {
|
|||
...weekdays.slice(effectiveFirstDay),
|
||||
...weekdays.slice(0, effectiveFirstDay),
|
||||
];
|
||||
|
||||
// Filter out weekends if hideWeekends is enabled
|
||||
const filteredWeekdays = hideWeekends
|
||||
? rotatedWeekdays.filter((_, index) => {
|
||||
// Calculate the actual day of week for this header position
|
||||
const dayOfWeek = (effectiveFirstDay + index) % 7;
|
||||
return dayOfWeek !== 0 && dayOfWeek !== 6; // Exclude Sunday (0) and Saturday (6)
|
||||
})
|
||||
: rotatedWeekdays;
|
||||
|
||||
let dayIndex = 0;
|
||||
|
||||
while (currentDayIter.isSameOrBefore(endOfWeek, "day")) {
|
||||
const isWeekend = currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
|
||||
|
||||
// Skip weekend days if hideWeekends is enabled
|
||||
if (hideWeekends && isWeekend) {
|
||||
currentDayIter.add(1, "day");
|
||||
continue; // Don't increment dayIndex for skipped days
|
||||
}
|
||||
|
||||
const dateStr = currentDayIter.format("YYYY-MM-DD");
|
||||
const headerCell = headerRow.createDiv("calendar-header-cell");
|
||||
dayHeaderCells[dateStr] = headerCell; // Store header cell if needed
|
||||
const weekdayEl = headerCell.createDiv("calendar-weekday");
|
||||
weekdayEl.textContent = rotatedWeekdays[dayIndex % 7]; // Use rotated weekday name
|
||||
weekdayEl.textContent = filteredWeekdays[dayIndex]; // Use filtered weekday names
|
||||
const dayNumEl = headerCell.createDiv("calendar-day-number");
|
||||
dayNumEl.textContent = currentDayIter.format("D"); // Date number
|
||||
|
||||
|
|
@ -96,6 +122,14 @@ export class WeekView extends CalendarViewComponent {
|
|||
currentDayIter = startOfWeek.clone();
|
||||
|
||||
while (currentDayIter.isSameOrBefore(endOfWeek, "day")) {
|
||||
const isWeekend = currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
|
||||
|
||||
// Skip weekend days if hideWeekends is enabled
|
||||
if (hideWeekends && isWeekend) {
|
||||
currentDayIter.add(1, "day");
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateStr = currentDayIter.format("YYYY-MM-DD");
|
||||
const dayCell = weekGrid.createEl("div", {
|
||||
cls: "calendar-day-column",
|
||||
|
|
@ -110,7 +144,7 @@ export class WeekView extends CalendarViewComponent {
|
|||
if (currentDayIter.isSame(moment(), "day")) {
|
||||
dayCell.addClass("is-today"); // Apply to the main day cell
|
||||
}
|
||||
if (currentDayIter.day() === 0 || currentDayIter.day() === 6) {
|
||||
if (isWeekend) {
|
||||
// This weekend check is based on Sun/Sat, might need adjustment if start day changes weekend definition visually
|
||||
dayCell.addClass("is-weekend"); // Apply to the main day cell
|
||||
}
|
||||
|
|
@ -230,50 +264,61 @@ export class WeekView extends CalendarViewComponent {
|
|||
/**
|
||||
* Initialize drag and drop functionality for calendar events
|
||||
*/
|
||||
private initializeDragAndDrop(dayEventContainers: { [key: string]: HTMLElement }): void {
|
||||
private initializeDragAndDrop(dayEventContainers: {
|
||||
[key: string]: HTMLElement;
|
||||
}): void {
|
||||
// Clean up existing sortable instances
|
||||
this.sortableInstances.forEach(instance => instance.destroy());
|
||||
this.sortableInstances.forEach((instance) => instance.destroy());
|
||||
this.sortableInstances = [];
|
||||
|
||||
// Initialize sortable for each day's events container
|
||||
Object.entries(dayEventContainers).forEach(([dateStr, eventsContainer]) => {
|
||||
if (eventsContainer) {
|
||||
const sortableInstance = Sortable.create(eventsContainer, {
|
||||
group: 'calendar-events',
|
||||
animation: 150,
|
||||
ghostClass: 'calendar-event-ghost',
|
||||
dragClass: 'calendar-event-dragging',
|
||||
onEnd: (event) => {
|
||||
this.handleDragEnd(event, dateStr);
|
||||
}
|
||||
});
|
||||
this.sortableInstances.push(sortableInstance);
|
||||
Object.entries(dayEventContainers).forEach(
|
||||
([dateStr, eventsContainer]) => {
|
||||
if (eventsContainer) {
|
||||
const sortableInstance = Sortable.create(eventsContainer, {
|
||||
group: "calendar-events",
|
||||
animation: 150,
|
||||
ghostClass: "calendar-event-ghost",
|
||||
dragClass: "calendar-event-dragging",
|
||||
onEnd: (event) => {
|
||||
this.handleDragEnd(event, dateStr);
|
||||
},
|
||||
});
|
||||
this.sortableInstances.push(sortableInstance);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end event to update task dates
|
||||
*/
|
||||
private async handleDragEnd(event: Sortable.SortableEvent, originalDateStr: string): Promise<void> {
|
||||
private async handleDragEnd(
|
||||
event: Sortable.SortableEvent,
|
||||
originalDateStr: string
|
||||
): Promise<void> {
|
||||
const eventEl = event.item;
|
||||
const eventId = eventEl.dataset.eventId;
|
||||
const targetContainer = event.to;
|
||||
const targetDateColumn = targetContainer.closest('.calendar-day-column');
|
||||
const targetDateColumn = targetContainer.closest(
|
||||
".calendar-day-column"
|
||||
);
|
||||
|
||||
if (!eventId || !targetDateColumn) {
|
||||
console.warn('Could not determine event ID or target date for drag operation');
|
||||
console.warn(
|
||||
"Could not determine event ID or target date for drag operation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDateStr = targetDateColumn.getAttribute('data-date');
|
||||
const targetDateStr = targetDateColumn.getAttribute("data-date");
|
||||
if (!targetDateStr || targetDateStr === originalDateStr) {
|
||||
// No date change, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the calendar event
|
||||
const calendarEvent = this.events.find(e => e.id === eventId);
|
||||
const calendarEvent = this.events.find((e) => e.id === eventId);
|
||||
if (!calendarEvent) {
|
||||
console.warn(`Calendar event with ID ${eventId} not found`);
|
||||
return;
|
||||
|
|
@ -281,9 +326,11 @@ export class WeekView extends CalendarViewComponent {
|
|||
|
||||
try {
|
||||
await this.updateTaskDate(calendarEvent, targetDateStr);
|
||||
console.log(`Task ${eventId} moved from ${originalDateStr} to ${targetDateStr}`);
|
||||
console.log(
|
||||
`Task ${eventId} moved from ${originalDateStr} to ${targetDateStr}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task date:', error);
|
||||
console.error("Failed to update task date:", error);
|
||||
// Revert the visual change by re-rendering
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -292,27 +339,30 @@ export class WeekView extends CalendarViewComponent {
|
|||
/**
|
||||
* Update task date based on the target date
|
||||
*/
|
||||
private async updateTaskDate(calendarEvent: CalendarEvent, targetDateStr: string): Promise<void> {
|
||||
private async updateTaskDate(
|
||||
calendarEvent: CalendarEvent,
|
||||
targetDateStr: string
|
||||
): Promise<void> {
|
||||
const targetDate = moment(targetDateStr).valueOf();
|
||||
const taskManager = this.plugin.taskManager;
|
||||
|
||||
if (!taskManager) {
|
||||
throw new Error('Task manager not available');
|
||||
throw new Error("Task manager not available");
|
||||
}
|
||||
|
||||
// Create updated task with new date
|
||||
const updatedTask = { ...calendarEvent };
|
||||
|
||||
// Determine which date field to update based on what the task currently has
|
||||
if (calendarEvent.dueDate) {
|
||||
updatedTask.dueDate = targetDate;
|
||||
} else if (calendarEvent.scheduledDate) {
|
||||
updatedTask.scheduledDate = targetDate;
|
||||
} else if (calendarEvent.startDate) {
|
||||
updatedTask.startDate = targetDate;
|
||||
if (calendarEvent.metadata.dueDate) {
|
||||
updatedTask.metadata.dueDate = targetDate;
|
||||
} else if (calendarEvent.metadata.scheduledDate) {
|
||||
updatedTask.metadata.scheduledDate = targetDate;
|
||||
} else if (calendarEvent.metadata.startDate) {
|
||||
updatedTask.metadata.startDate = targetDate;
|
||||
} else {
|
||||
// Default to due date if no date is set
|
||||
updatedTask.dueDate = targetDate;
|
||||
updatedTask.metadata.dueDate = targetDate;
|
||||
}
|
||||
|
||||
// Update the task
|
||||
|
|
@ -323,7 +373,7 @@ export class WeekView extends CalendarViewComponent {
|
|||
* Clean up sortable instances when component is destroyed
|
||||
*/
|
||||
onunload(): void {
|
||||
this.sortableInstances.forEach(instance => instance.destroy());
|
||||
this.sortableInstances.forEach((instance) => instance.destroy());
|
||||
this.sortableInstances = [];
|
||||
super.onunload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export class YearView extends CalendarViewComponent {
|
|||
const year = this.currentDate.year();
|
||||
this.containerEl.empty();
|
||||
this.containerEl.addClass("view-year");
|
||||
|
||||
console.log(
|
||||
`YearView: Rendering year ${year}. Total events received: ${this.events.length}`
|
||||
); // Log total events
|
||||
|
|
@ -68,6 +69,14 @@ export class YearView extends CalendarViewComponent {
|
|||
const firstDayOfWeekSetting = (
|
||||
viewConfig.specificConfig as CalendarSpecificConfig
|
||||
).firstDayOfWeek;
|
||||
const hideWeekends = (viewConfig.specificConfig as CalendarSpecificConfig)?.hideWeekends ?? false;
|
||||
|
||||
// Add hide-weekends class if weekend hiding is enabled
|
||||
if (hideWeekends) {
|
||||
this.containerEl.addClass("hide-weekends");
|
||||
} else {
|
||||
this.containerEl.removeClass("hide-weekends");
|
||||
}
|
||||
// Default to Sunday (0) if the setting is undefined, following 0=Sun, 1=Mon, ..., 6=Sat
|
||||
const effectiveFirstDay =
|
||||
firstDayOfWeekSetting === undefined ? 0 : firstDayOfWeekSetting;
|
||||
|
|
@ -111,7 +120,8 @@ export class YearView extends CalendarViewComponent {
|
|||
monthBody,
|
||||
monthMoment,
|
||||
daysWithEvents,
|
||||
effectiveFirstDay
|
||||
effectiveFirstDay,
|
||||
hideWeekends
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,8 +153,8 @@ export class YearView extends CalendarViewComponent {
|
|||
| undefined
|
||||
)[] = [
|
||||
event.start,
|
||||
event.scheduledDate, // Assuming 'scheduled' exists on CalendarEvent
|
||||
event.dueDate, // Assuming 'due' exists on CalendarEvent
|
||||
event.metadata.scheduledDate, // Assuming 'scheduled' exists on CalendarEvent
|
||||
event.metadata.dueDate, // Assuming 'due' exists on CalendarEvent
|
||||
];
|
||||
|
||||
datesToCheck.forEach((dateInput) => {
|
||||
|
|
@ -169,7 +179,8 @@ export class YearView extends CalendarViewComponent {
|
|||
container: HTMLElement,
|
||||
monthMoment: moment.Moment,
|
||||
daysWithEvents: Set<number>,
|
||||
effectiveFirstDay: number // Pass the effective first day
|
||||
effectiveFirstDay: number, // Pass the effective first day
|
||||
hideWeekends: boolean // Pass the weekend hiding setting
|
||||
) {
|
||||
container.empty(); // Clear placeholder
|
||||
container.addClass("mini-month-grid");
|
||||
|
|
@ -181,25 +192,67 @@ export class YearView extends CalendarViewComponent {
|
|||
...weekdays.slice(effectiveFirstDay),
|
||||
...weekdays.slice(0, effectiveFirstDay),
|
||||
];
|
||||
rotatedWeekdays.forEach((day) => {
|
||||
|
||||
// Filter out weekends if hideWeekends is enabled
|
||||
const filteredWeekdays = hideWeekends
|
||||
? rotatedWeekdays.filter((_, index) => {
|
||||
// Calculate the actual day of week for this header position
|
||||
const dayOfWeek = (effectiveFirstDay + index) % 7;
|
||||
return dayOfWeek !== 0 && dayOfWeek !== 6; // Exclude Sunday (0) and Saturday (6)
|
||||
})
|
||||
: rotatedWeekdays;
|
||||
|
||||
filteredWeekdays.forEach((day) => {
|
||||
headerRow.createDiv("mini-weekday").textContent = day;
|
||||
});
|
||||
|
||||
// Calculate grid boundaries using effective first day
|
||||
const monthStart = monthMoment.clone().startOf("month");
|
||||
const daysToSubtractStart =
|
||||
(monthStart.weekday() - effectiveFirstDay + 7) % 7;
|
||||
const gridStart = monthStart
|
||||
.clone()
|
||||
.subtract(daysToSubtractStart, "days");
|
||||
|
||||
const monthEnd = monthMoment.clone().endOf("month");
|
||||
const daysToAddEnd =
|
||||
(effectiveFirstDay + 6 - monthEnd.weekday() + 7) % 7;
|
||||
const gridEnd = monthEnd.clone().add(daysToAddEnd, "days");
|
||||
|
||||
let gridStart: moment.Moment;
|
||||
let gridEnd: moment.Moment;
|
||||
|
||||
if (hideWeekends) {
|
||||
// When weekends are hidden, adjust grid to start and end on work days
|
||||
// Find the first work day of the week containing the start of month
|
||||
gridStart = monthStart.clone();
|
||||
const daysToSubtractStart = (monthStart.weekday() - effectiveFirstDay + 7) % 7;
|
||||
gridStart.subtract(daysToSubtractStart, "days");
|
||||
|
||||
// Ensure gridStart is not a weekend
|
||||
while (gridStart.day() === 0 || gridStart.day() === 6) {
|
||||
gridStart.add(1, "day");
|
||||
}
|
||||
|
||||
// Find the last work day of the week containing the end of month
|
||||
gridEnd = monthEnd.clone();
|
||||
const daysToAddEnd = (effectiveFirstDay + 4 - monthEnd.weekday() + 7) % 7; // 4 = Friday in work week
|
||||
gridEnd.add(daysToAddEnd, "days");
|
||||
|
||||
// Ensure gridEnd is not a weekend
|
||||
while (gridEnd.day() === 0 || gridEnd.day() === 6) {
|
||||
gridEnd.subtract(1, "day");
|
||||
}
|
||||
} else {
|
||||
// Original logic for when weekends are shown
|
||||
const daysToSubtractStart = (monthStart.weekday() - effectiveFirstDay + 7) % 7;
|
||||
gridStart = monthStart.clone().subtract(daysToSubtractStart, "days");
|
||||
|
||||
const daysToAddEnd = (effectiveFirstDay + 6 - monthEnd.weekday() + 7) % 7;
|
||||
gridEnd = monthEnd.clone().add(daysToAddEnd, "days");
|
||||
}
|
||||
|
||||
let currentDayIter = gridStart.clone();
|
||||
while (currentDayIter.isSameOrBefore(gridEnd, "day")) {
|
||||
const isWeekend = currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
|
||||
|
||||
// Skip weekend days if hideWeekends is enabled
|
||||
if (hideWeekends && isWeekend) {
|
||||
currentDayIter.add(1, "day");
|
||||
continue;
|
||||
}
|
||||
|
||||
const cell = container.createEl("div", {
|
||||
cls: "mini-day-cell",
|
||||
attr: {
|
||||
|
|
|
|||
|
|
@ -358,16 +358,21 @@ export class GanttComponent extends Component {
|
|||
|
||||
this.tasks.forEach((task) => {
|
||||
const taskStart =
|
||||
task.startDate || task.scheduledDate || task.createdDate;
|
||||
const taskEnd = task.dueDate || task.completedDate;
|
||||
task.metadata.startDate ||
|
||||
task.metadata.scheduledDate ||
|
||||
task.metadata.createdDate;
|
||||
const taskEnd =
|
||||
task.metadata.dueDate || task.metadata.completedDate;
|
||||
|
||||
if (taskStart) {
|
||||
const startTs = new Date(taskStart).getTime();
|
||||
if (!isNaN(startTs)) {
|
||||
minTimestamp = Math.min(minTimestamp, startTs);
|
||||
}
|
||||
} else if (task.createdDate) {
|
||||
const creationTs = new Date(task.createdDate).getTime();
|
||||
} else if (task.metadata.createdDate) {
|
||||
const creationTs = new Date(
|
||||
task.metadata.createdDate
|
||||
).getTime();
|
||||
if (!isNaN(creationTs)) {
|
||||
minTimestamp = Math.min(minTimestamp, creationTs);
|
||||
}
|
||||
|
|
@ -376,7 +381,8 @@ export class GanttComponent extends Component {
|
|||
if (taskEnd) {
|
||||
const endTs = new Date(taskEnd).getTime();
|
||||
if (!isNaN(endTs)) {
|
||||
const isMilestone = !task.startDate && task.dueDate;
|
||||
const isMilestone =
|
||||
!task.metadata.startDate && task.metadata.dueDate;
|
||||
maxTimestamp = Math.max(
|
||||
maxTimestamp,
|
||||
isMilestone
|
||||
|
|
@ -470,8 +476,9 @@ export class GanttComponent extends Component {
|
|||
let endX: number | undefined;
|
||||
let isMilestone = false;
|
||||
|
||||
const taskStart = task.startDate || task.scheduledDate;
|
||||
let taskDue = task.dueDate;
|
||||
const taskStart =
|
||||
task.metadata.startDate || task.metadata.scheduledDate;
|
||||
let taskDue = task.metadata.dueDate;
|
||||
|
||||
if (taskStart) {
|
||||
const startDate = new Date(taskStart);
|
||||
|
|
@ -493,7 +500,7 @@ export class GanttComponent extends Component {
|
|||
this.dayWidth
|
||||
);
|
||||
}
|
||||
} else if (task.completedDate && taskStart) {
|
||||
} else if (task.metadata.completedDate && taskStart) {
|
||||
// Optional: end bar at completion date if no due date
|
||||
}
|
||||
|
||||
|
|
@ -577,10 +584,10 @@ export class GanttComponent extends Component {
|
|||
private sortTasks(tasks: Task[]): Task[] {
|
||||
// Keep existing sort logic, using dateHelper
|
||||
return tasks.sort((a, b) => {
|
||||
const startA = a.startDate || a.scheduledDate;
|
||||
const startB = b.startDate || b.scheduledDate;
|
||||
const dueA = a.dueDate;
|
||||
const dueB = b.dueDate;
|
||||
const startA = a.metadata.startDate || a.metadata.scheduledDate;
|
||||
const startB = b.metadata.startDate || b.metadata.scheduledDate;
|
||||
const dueA = a.metadata.dueDate;
|
||||
const dueB = b.metadata.dueDate;
|
||||
|
||||
if (startA && startB) {
|
||||
const dateA = new Date(startA).getTime();
|
||||
|
|
@ -728,9 +735,9 @@ export class GanttComponent extends Component {
|
|||
if (task) {
|
||||
this.scrollToDate(
|
||||
new Date(
|
||||
task.dueDate ||
|
||||
task.startDate ||
|
||||
task.scheduledDate!
|
||||
task.metadata.dueDate ||
|
||||
task.metadata.startDate ||
|
||||
task.metadata.scheduledDate!
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -745,9 +752,9 @@ export class GanttComponent extends Component {
|
|||
if (task) {
|
||||
this.scrollToDate(
|
||||
new Date(
|
||||
task.startDate ||
|
||||
task.dueDate ||
|
||||
task.scheduledDate!
|
||||
task.metadata.startDate ||
|
||||
task.metadata.dueDate ||
|
||||
task.metadata.scheduledDate!
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1079,21 +1086,24 @@ export class GanttComponent extends Component {
|
|||
case "status":
|
||||
return task.status === filter.value;
|
||||
case "tag":
|
||||
return task.tags.some(
|
||||
return task.metadata.tags.some(
|
||||
(tag) =>
|
||||
typeof tag === "string" &&
|
||||
tag === filter.value
|
||||
);
|
||||
case "project":
|
||||
return task.project === filter.value;
|
||||
return task.metadata.project === filter.value;
|
||||
case "context":
|
||||
return task.context === filter.value;
|
||||
return task.metadata.context === filter.value;
|
||||
case "priority":
|
||||
// Convert the selected filter value (icon/text) back to its numerical representation
|
||||
const expectedPriorityNumber =
|
||||
PRIORITY_MAP[filter.value];
|
||||
// Compare the task's numerical priority
|
||||
return task.priority === expectedPriorityNumber;
|
||||
return (
|
||||
task.metadata.priority ===
|
||||
expectedPriorityNumber
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
(filter.value === "Yes" && task.completed) ||
|
||||
|
|
|
|||
|
|
@ -142,9 +142,12 @@ export class TaskRendererComponent extends Component {
|
|||
if (task.status && task.status.trim()) {
|
||||
taskElement.classList.add(`status-${task.status.trim()}`);
|
||||
}
|
||||
if (task.priority && String(task.priority).trim()) {
|
||||
if (
|
||||
task.metadata.priority &&
|
||||
String(task.metadata.priority).trim()
|
||||
) {
|
||||
taskElement.classList.add(
|
||||
`priority-${String(task.priority).trim()}`
|
||||
`priority-${String(task.metadata.priority).trim()}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -200,8 +203,8 @@ export class TaskRendererComponent extends Component {
|
|||
group.setAttribute(
|
||||
"title",
|
||||
`${task.content}\nDue: ${
|
||||
task.dueDate
|
||||
? new Date(task.dueDate).toLocaleDateString()
|
||||
task.metadata.dueDate
|
||||
? new Date(task.metadata.dueDate).toLocaleDateString()
|
||||
: "N/A"
|
||||
}`
|
||||
);
|
||||
|
|
@ -222,9 +225,12 @@ export class TaskRendererComponent extends Component {
|
|||
if (task.status && task.status.trim()) {
|
||||
taskElement.classList.add(`status-${task.status.trim()}`);
|
||||
}
|
||||
if (task.priority && String(task.priority).trim()) {
|
||||
if (
|
||||
task.metadata.priority &&
|
||||
String(task.metadata.priority).trim()
|
||||
) {
|
||||
taskElement.classList.add(
|
||||
`priority-${String(task.priority).trim()}`
|
||||
`priority-${String(task.metadata.priority).trim()}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -232,12 +238,12 @@ export class TaskRendererComponent extends Component {
|
|||
group.setAttribute(
|
||||
"title",
|
||||
`${task.content}\nStart: ${
|
||||
task.startDate
|
||||
? new Date(task.startDate).toLocaleDateString()
|
||||
task.metadata.startDate
|
||||
? new Date(task.metadata.startDate).toLocaleDateString()
|
||||
: "N/A"
|
||||
}\nDue: ${
|
||||
task.dueDate
|
||||
? new Date(task.dueDate).toLocaleDateString()
|
||||
task.metadata.dueDate
|
||||
? new Date(task.metadata.dueDate).toLocaleDateString()
|
||||
: "N/A"
|
||||
}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CountHabitProps } from "../../../types/habit-card";
|
|||
import { HabitCard } from "./habitcard";
|
||||
import { t } from "../../../translations/helper";
|
||||
import TaskProgressBarPlugin from "../../../index";
|
||||
import { getTodayLocalDateString } from "../../../utils/dateUtil";
|
||||
|
||||
export class CountHabitCard extends HabitCard {
|
||||
constructor(
|
||||
|
|
@ -39,7 +40,7 @@ export class CountHabitCard extends HabitCard {
|
|||
}
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const today = getTodayLocalDateString();
|
||||
let countToday = this.habit.completions[today] ?? 0;
|
||||
|
||||
const infoDiv = contentWrapper.createDiv(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DailyHabitProps } from "../../../types/habit-card";
|
|||
import { HabitCard } from "./habitcard";
|
||||
import { t } from "../../../translations/helper";
|
||||
import TaskProgressBarPlugin from "../../../index";
|
||||
import { getTodayLocalDateString } from "../../../utils/dateUtil";
|
||||
|
||||
export class DailyHabitCard extends HabitCard {
|
||||
constructor(
|
||||
|
|
@ -49,7 +50,7 @@ export class DailyHabitCard extends HabitCard {
|
|||
type: "checkbox",
|
||||
cls: "habit-checkbox",
|
||||
});
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const today = getTodayLocalDateString();
|
||||
|
||||
// Check if completed based on completion text or any value
|
||||
let isCompletedToday = false;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
MappingHabitProps,
|
||||
} from "../../../types/habit-card";
|
||||
import TaskProgressBarPlugin from "../../../index";
|
||||
import { getTodayLocalDateString, getLocalDateString } from "../../../utils/dateUtil";
|
||||
|
||||
function getDatesInRange(startDate: string, endDate: string): string[] {
|
||||
const dates = [];
|
||||
|
|
@ -102,8 +103,8 @@ export class HabitCard extends Component {
|
|||
endDate.getTime() - (countsMap[size] - 1) * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const dates = getDatesInRange(
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[0]
|
||||
getLocalDateString(startDate),
|
||||
getLocalDateString(endDate)
|
||||
);
|
||||
|
||||
// Render dates in reverse chronological order (most recent first)
|
||||
|
|
@ -177,7 +178,7 @@ export class HabitCard extends Component {
|
|||
const habitToUpdate = JSON.parse(
|
||||
JSON.stringify(currentHabits[habitIndex])
|
||||
);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const today = getTodayLocalDateString();
|
||||
|
||||
// 2. Calculate new completion state based on habit type
|
||||
let newCompletionValue: any;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { MappingHabitProps } from "../../../types/habit-card";
|
||||
import { HabitCard } from "./habitcard";
|
||||
import TaskProgressBarPlugin from "../../../index";
|
||||
import { getTodayLocalDateString } from "../../../utils/dateUtil";
|
||||
|
||||
export class MappingHabitCard extends HabitCard {
|
||||
constructor(
|
||||
|
|
@ -68,7 +69,7 @@ export class MappingHabitCard extends HabitCard {
|
|||
);
|
||||
|
||||
const controlsDiv = contentWrapper.createDiv({ cls: "habit-controls" });
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const today = getTodayLocalDateString();
|
||||
const defaultValue = Object.keys(this.habit.mapping || {})
|
||||
.map(Number)
|
||||
.includes(3)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { HabitCard } from "./habitcard";
|
|||
import TaskProgressBarPlugin from "../../../index";
|
||||
import { t } from "../../../translations/helper";
|
||||
import { EventDetailModal } from "../habit";
|
||||
import { getTodayLocalDateString } from "../../../utils/dateUtil";
|
||||
|
||||
function renderPieDotSVG(completed: number, total: number): string {
|
||||
if (total <= 0) return "";
|
||||
|
|
@ -112,7 +113,7 @@ export class ScheduledHabitCard extends HabitCard {
|
|||
);
|
||||
|
||||
const controlsDiv = contentWrapper.createDiv({ cls: "habit-controls" });
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const today = getTodayLocalDateString();
|
||||
// Ensure completions for today exists and is an object
|
||||
const todaysCompletions: Record<string, string> =
|
||||
typeof this.habit.completions[today] === "object" &&
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function buildFilterOptionsFromTasks(tasks: Task[]): FilterCategory[] {
|
|||
if (task.status) statuses.add(task.status);
|
||||
|
||||
// Tags
|
||||
task.tags.forEach((tag) => {
|
||||
task.metadata.tags.forEach((tag) => {
|
||||
// Skip non-string tags
|
||||
if (typeof tag === "string") {
|
||||
tags.add(tag);
|
||||
|
|
@ -34,13 +34,14 @@ export function buildFilterOptionsFromTasks(tasks: Task[]): FilterCategory[] {
|
|||
});
|
||||
|
||||
// Project
|
||||
if (task.project) projects.add(task.project);
|
||||
if (task.metadata.project) projects.add(task.metadata.project);
|
||||
|
||||
// Context
|
||||
if (task.context) contexts.add(task.context);
|
||||
if (task.metadata.context) contexts.add(task.metadata.context);
|
||||
|
||||
// Priority
|
||||
if (task.priority !== undefined) priorities.add(task.priority);
|
||||
if (task.metadata.priority !== undefined)
|
||||
priorities.add(task.metadata.priority);
|
||||
|
||||
// File Path
|
||||
if (task.filePath) filePaths.add(task.filePath);
|
||||
|
|
@ -319,7 +320,6 @@ export class FilterComponent extends Component {
|
|||
// Clear active filters array
|
||||
this.activeFilters = [];
|
||||
|
||||
// Clear the DOM container (safer than innerHTML='')
|
||||
this.filtersContainer.empty();
|
||||
|
||||
this.updateClearAllButton();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MarkdownRendererComponent } from "../MarkdownRenderer"; // Adjust path
|
|||
import TaskProgressBarPlugin from "../../index"; // Adjust path
|
||||
import { KanbanSpecificConfig } from "../../common/setting-definition";
|
||||
import { createTaskCheckbox } from "../task-view/details";
|
||||
import { getEffectiveProject } from "../../utils/taskUtil";
|
||||
|
||||
export class KanbanCardComponent extends Component {
|
||||
public element: HTMLElement;
|
||||
|
|
@ -46,8 +47,10 @@ export class KanbanCardComponent extends Component {
|
|||
if (this.task.completed) {
|
||||
this.element.classList.add("task-completed");
|
||||
}
|
||||
if (this.task.priority) {
|
||||
this.element.classList.add(`priority-${this.task.priority}`);
|
||||
if (this.task.metadata.priority) {
|
||||
this.element.classList.add(
|
||||
`priority-${this.task.metadata.priority}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Card Content ---
|
||||
|
|
@ -134,28 +137,29 @@ export class KanbanCardComponent extends Component {
|
|||
|
||||
// Display dates (similar to TaskListItemComponent)
|
||||
if (!this.task.completed) {
|
||||
if (this.task.dueDate) this.renderDueDate();
|
||||
if (this.task.metadata.dueDate) this.renderDueDate();
|
||||
// Add scheduled, start dates if needed
|
||||
} else {
|
||||
if (this.task.completedDate) this.renderCompletionDate();
|
||||
if (this.task.metadata.completedDate) this.renderCompletionDate();
|
||||
// Add created date if needed
|
||||
}
|
||||
|
||||
// Project (if not grouped by project already) - Kanban might inherently group by status
|
||||
if (this.task.project) this.renderProject();
|
||||
if (getEffectiveProject(this.task)) this.renderProject();
|
||||
|
||||
// Tags
|
||||
if (this.task.tags && this.task.tags.length > 0) this.renderTags();
|
||||
if (this.task.metadata.tags && this.task.metadata.tags.length > 0)
|
||||
this.renderTags();
|
||||
|
||||
// Priority
|
||||
if (this.task.priority) this.renderPriority();
|
||||
if (this.task.metadata.priority) this.renderPriority();
|
||||
}
|
||||
|
||||
private renderDueDate() {
|
||||
const dueEl = this.metadataEl.createEl("div", {
|
||||
cls: ["task-date", "task-due-date"],
|
||||
});
|
||||
const dueDate = new Date(this.task.dueDate || "");
|
||||
const dueDate = new Date(this.task.metadata.dueDate || "");
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
|
|
@ -187,7 +191,7 @@ export class KanbanCardComponent extends Component {
|
|||
const completedEl = this.metadataEl.createEl("div", {
|
||||
cls: ["task-date", "task-done-date"],
|
||||
});
|
||||
const completedDate = new Date(this.task.completedDate || "");
|
||||
const completedDate = new Date(this.task.metadata.completedDate || "");
|
||||
completedEl.textContent = `Done: ${completedDate.toLocaleDateString(
|
||||
undefined,
|
||||
{ month: "short", day: "numeric" }
|
||||
|
|
@ -199,17 +203,29 @@ export class KanbanCardComponent extends Component {
|
|||
}
|
||||
|
||||
private renderProject() {
|
||||
const effectiveProject = getEffectiveProject(this.task);
|
||||
if (!effectiveProject) return;
|
||||
|
||||
const projectEl = this.metadataEl.createEl("div", {
|
||||
cls: ["task-project", "clickable-metadata"],
|
||||
});
|
||||
projectEl.textContent = this.task.project || "";
|
||||
projectEl.setAttribute("aria-label", `Project: ${this.task.project}`);
|
||||
|
||||
// Add visual indicator for tgProject
|
||||
if (!this.task.metadata.project && this.task.metadata.tgProject) {
|
||||
projectEl.addClass("task-project-tg");
|
||||
projectEl.title = `Project from ${
|
||||
this.task.metadata.tgProject.type
|
||||
}: ${this.task.metadata.tgProject.source || ""}`;
|
||||
}
|
||||
|
||||
projectEl.textContent = effectiveProject;
|
||||
projectEl.setAttribute("aria-label", `Project: ${effectiveProject}`);
|
||||
|
||||
// Make project clickable for filtering
|
||||
this.registerDomEvent(projectEl, "click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (this.params.onFilterApply && this.task.project) {
|
||||
this.params.onFilterApply("project", this.task.project);
|
||||
if (this.params.onFilterApply && effectiveProject) {
|
||||
this.params.onFilterApply("project", effectiveProject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -218,7 +234,7 @@ export class KanbanCardComponent extends Component {
|
|||
const tagsContainer = this.metadataEl.createEl("div", {
|
||||
cls: "task-tags-container",
|
||||
});
|
||||
this.task.tags.forEach((tag) => {
|
||||
this.task.metadata.tags.forEach((tag) => {
|
||||
// Skip non-string tags
|
||||
if (typeof tag !== "string") {
|
||||
return;
|
||||
|
|
@ -250,19 +266,26 @@ export class KanbanCardComponent extends Component {
|
|||
const priorityEl = this.metadataEl.createDiv({
|
||||
cls: [
|
||||
"task-priority",
|
||||
`priority-${this.task.priority}`,
|
||||
`priority-${this.task.metadata.priority}`,
|
||||
"clickable-metadata",
|
||||
],
|
||||
});
|
||||
priorityEl.textContent = `${"!".repeat(this.task.priority || 0)}`;
|
||||
priorityEl.setAttribute("aria-label", `Priority ${this.task.priority}`);
|
||||
priorityEl.textContent = `${"!".repeat(
|
||||
this.task.metadata.priority || 0
|
||||
)}`;
|
||||
priorityEl.setAttribute(
|
||||
"aria-label",
|
||||
`Priority ${this.task.metadata.priority}`
|
||||
);
|
||||
|
||||
// Make priority clickable for filtering
|
||||
this.registerDomEvent(priorityEl, "click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (this.params.onFilterApply && this.task.priority) {
|
||||
if (this.params.onFilterApply && this.task.metadata.priority) {
|
||||
// Convert numeric priority to icon representation for filter compatibility
|
||||
const priorityIcon = this.getPriorityIcon(this.task.priority);
|
||||
const priorityIcon = this.getPriorityIcon(
|
||||
this.task.metadata.priority
|
||||
);
|
||||
this.params.onFilterApply("priority", priorityIcon);
|
||||
}
|
||||
});
|
||||
|
|
@ -317,11 +340,15 @@ export class KanbanCardComponent extends Component {
|
|||
if (oldTask.completed !== newTask.completed) {
|
||||
this.element.classList.toggle("task-completed", newTask.completed);
|
||||
}
|
||||
if (oldTask.priority !== newTask.priority) {
|
||||
if (oldTask.priority)
|
||||
this.element.classList.remove(`priority-${oldTask.priority}`);
|
||||
if (newTask.priority)
|
||||
this.element.classList.add(`priority-${newTask.priority}`);
|
||||
if (oldTask.metadata.priority !== newTask.metadata.priority) {
|
||||
if (oldTask.metadata.priority)
|
||||
this.element.classList.remove(
|
||||
`priority-${oldTask.metadata.priority}`
|
||||
);
|
||||
if (newTask.metadata.priority)
|
||||
this.element.classList.add(
|
||||
`priority-${newTask.metadata.priority}`
|
||||
);
|
||||
}
|
||||
|
||||
// Re-render content and metadata if needed
|
||||
|
|
@ -334,11 +361,12 @@ export class KanbanCardComponent extends Component {
|
|||
}
|
||||
// Check if metadata-relevant fields changed
|
||||
if (
|
||||
oldTask.dueDate !== newTask.dueDate ||
|
||||
oldTask.completedDate !== newTask.completedDate ||
|
||||
oldTask.tags?.join(",") !== newTask.tags?.join(",") || // Simple comparison
|
||||
oldTask.priority !== newTask.priority ||
|
||||
oldTask.project !== newTask.project
|
||||
oldTask.metadata.dueDate !== newTask.metadata.dueDate ||
|
||||
oldTask.metadata.completedDate !== newTask.metadata.completedDate ||
|
||||
oldTask.metadata.tags?.join(",") !==
|
||||
newTask.metadata.tags?.join(",") || // Simple comparison
|
||||
oldTask.metadata.priority !== newTask.metadata.priority ||
|
||||
oldTask.metadata.project !== newTask.metadata.project
|
||||
) {
|
||||
this.renderMetadata();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
KanbanSpecificConfig,
|
||||
KanbanColumnConfig,
|
||||
} from "../../common/setting-definition";
|
||||
import { getEffectiveProject, isProjectReadonly } from "../../utils/taskUtil";
|
||||
|
||||
// CSS classes for drop indicators
|
||||
const DROP_INDICATOR_BEFORE_CLASS = "tg-kanban-card--drop-indicator-before";
|
||||
|
|
@ -295,14 +296,14 @@ export class KanbanComponent extends Component {
|
|||
// Support for nested tags - include child tags
|
||||
return this.matchesTagFilter(task, filter.value);
|
||||
case "project":
|
||||
return task.project === filter.value;
|
||||
return task.metadata.project === filter.value;
|
||||
case "context":
|
||||
return task.context === filter.value;
|
||||
return task.metadata.context === filter.value;
|
||||
case "priority":
|
||||
const expectedPriority =
|
||||
PRIORITY_MAP[filter.value] ||
|
||||
parseInt(filter.value);
|
||||
return task.priority === expectedPriority;
|
||||
return task.metadata.priority === expectedPriority;
|
||||
case "completed":
|
||||
return (
|
||||
(filter.value === "Yes" && task.completed) ||
|
||||
|
|
@ -327,9 +328,10 @@ export class KanbanComponent extends Component {
|
|||
|
||||
// Enhanced tag filtering to support nested tags
|
||||
private matchesTagFilter(task: Task, filterTag: string): boolean {
|
||||
if (!task.tags || task.tags.length === 0) return false;
|
||||
if (!task.metadata.tags || task.metadata.tags.length === 0)
|
||||
return false;
|
||||
|
||||
return task.tags.some((taskTag) => {
|
||||
return task.metadata.tags.some((taskTag) => {
|
||||
// Skip non-string tags
|
||||
if (typeof taskTag !== "string") {
|
||||
return false;
|
||||
|
|
@ -576,8 +578,8 @@ export class KanbanComponent extends Component {
|
|||
// Get unique tags from all tasks
|
||||
const allTags = new Set<string>();
|
||||
this.tasks.forEach((task) => {
|
||||
if (task.tags) {
|
||||
task.tags.forEach((tag) => {
|
||||
if (task.metadata.tags) {
|
||||
task.metadata.tags.forEach((tag) => {
|
||||
// Skip non-string tags
|
||||
if (typeof tag === "string") {
|
||||
allTags.add(tag);
|
||||
|
|
@ -597,11 +599,12 @@ export class KanbanComponent extends Component {
|
|||
});
|
||||
return tagColumns;
|
||||
case "project":
|
||||
// Get unique projects from all tasks
|
||||
// Get unique projects from all tasks (including tgProject)
|
||||
const allProjects = new Set<string>();
|
||||
this.tasks.forEach((task) => {
|
||||
if (task.project) {
|
||||
allProjects.add(task.project);
|
||||
const effectiveProject = getEffectiveProject(task);
|
||||
if (effectiveProject) {
|
||||
allProjects.add(effectiveProject);
|
||||
}
|
||||
});
|
||||
const projectColumns = Array.from(allProjects).map(
|
||||
|
|
@ -621,8 +624,8 @@ export class KanbanComponent extends Component {
|
|||
// Get unique contexts from all tasks
|
||||
const allContexts = new Set<string>();
|
||||
this.tasks.forEach((task) => {
|
||||
if (task.context) {
|
||||
allContexts.add(task.context);
|
||||
if (task.metadata.context) {
|
||||
allContexts.add(task.metadata.context);
|
||||
}
|
||||
});
|
||||
const contextColumns = Array.from(allContexts).map(
|
||||
|
|
@ -722,28 +725,32 @@ export class KanbanComponent extends Component {
|
|||
|
||||
switch (field) {
|
||||
case "priority":
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
const priorityA = a.metadata.priority ?? 0;
|
||||
const priorityB = b.metadata.priority ?? 0;
|
||||
comparison = priorityA - priorityB;
|
||||
break;
|
||||
case "dueDate":
|
||||
const dueDateA = a.dueDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.dueDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const dueDateA = a.metadata.dueDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.metadata.dueDate ?? Number.MAX_SAFE_INTEGER;
|
||||
comparison = dueDateA - dueDateB;
|
||||
break;
|
||||
case "scheduledDate":
|
||||
const scheduledA = a.scheduledDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const scheduledB = b.scheduledDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const scheduledA =
|
||||
a.metadata.scheduledDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const scheduledB =
|
||||
b.metadata.scheduledDate ?? Number.MAX_SAFE_INTEGER;
|
||||
comparison = scheduledA - scheduledB;
|
||||
break;
|
||||
case "startDate":
|
||||
const startA = a.startDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const startB = b.startDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const startA = a.metadata.startDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const startB = b.metadata.startDate ?? Number.MAX_SAFE_INTEGER;
|
||||
comparison = startA - startB;
|
||||
break;
|
||||
case "createdDate":
|
||||
const createdA = a.createdDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const createdB = b.createdDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const createdA =
|
||||
a.metadata.createdDate ?? Number.MAX_SAFE_INTEGER;
|
||||
const createdB =
|
||||
b.metadata.createdDate ?? Number.MAX_SAFE_INTEGER;
|
||||
comparison = createdA - createdB;
|
||||
break;
|
||||
}
|
||||
|
|
@ -935,7 +942,7 @@ export class KanbanComponent extends Component {
|
|||
// Update the specific property based on groupBy type
|
||||
switch (groupBy) {
|
||||
case "priority":
|
||||
updatedTask.priority =
|
||||
updatedTask.metadata.priority =
|
||||
newValue === null || newValue === ""
|
||||
? undefined
|
||||
: Number(newValue);
|
||||
|
|
@ -943,11 +950,11 @@ export class KanbanComponent extends Component {
|
|||
case "tags":
|
||||
if (newValue === null || newValue === "") {
|
||||
// Moving to "No Tags" column - remove all tags
|
||||
updatedTask.tags = [];
|
||||
updatedTask.metadata.tags = [];
|
||||
} else {
|
||||
// Moving to a specific tag column
|
||||
// Use the oldValue parameter to determine which tag to remove
|
||||
let currentTags = updatedTask.tags || [];
|
||||
let currentTags = updatedTask.metadata.tags || [];
|
||||
|
||||
console.log("Tags update - current tags:", currentTags);
|
||||
console.log("Tags update - oldValue:", oldValue);
|
||||
|
|
@ -999,15 +1006,18 @@ export class KanbanComponent extends Component {
|
|||
}
|
||||
|
||||
console.log("Tags after adding new:", currentTags);
|
||||
updatedTask.tags = currentTags;
|
||||
updatedTask.metadata.tags = currentTags;
|
||||
}
|
||||
break;
|
||||
case "project":
|
||||
updatedTask.project =
|
||||
newValue === null || newValue === "" ? undefined : newValue;
|
||||
// Only update project if it's not a read-only tgProject
|
||||
if (!isProjectReadonly(taskToUpdate)) {
|
||||
updatedTask.metadata.project =
|
||||
newValue === null || newValue === "" ? undefined : newValue;
|
||||
}
|
||||
break;
|
||||
case "context":
|
||||
updatedTask.context =
|
||||
updatedTask.metadata.context =
|
||||
newValue === null || newValue === "" ? undefined : newValue;
|
||||
break;
|
||||
case "dueDate":
|
||||
|
|
@ -1016,11 +1026,11 @@ export class KanbanComponent extends Component {
|
|||
// For date fields, we need to convert the category back to an actual date
|
||||
const dateValue = this.convertDateCategoryToTimestamp(newValue);
|
||||
if (groupBy === "dueDate") {
|
||||
updatedTask.dueDate = dateValue;
|
||||
updatedTask.metadata.dueDate = dateValue;
|
||||
} else if (groupBy === "scheduledDate") {
|
||||
updatedTask.scheduledDate = dateValue;
|
||||
updatedTask.metadata.scheduledDate = dateValue;
|
||||
} else if (groupBy === "startDate") {
|
||||
updatedTask.startDate = dateValue;
|
||||
updatedTask.metadata.startDate = dateValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
|
@ -1053,29 +1063,32 @@ export class KanbanComponent extends Component {
|
|||
switch (groupBy) {
|
||||
case "priority":
|
||||
if (value === null || value === "") {
|
||||
return !task.priority;
|
||||
return !task.metadata.priority;
|
||||
}
|
||||
return task.priority === value;
|
||||
return task.metadata.priority === value;
|
||||
case "tags":
|
||||
if (value === null || value === "") {
|
||||
return !task.tags || task.tags.length === 0;
|
||||
return (
|
||||
!task.metadata.tags ||
|
||||
task.metadata.tags.length === 0
|
||||
);
|
||||
}
|
||||
return (
|
||||
task.tags &&
|
||||
task.tags.some(
|
||||
task.metadata.tags &&
|
||||
task.metadata.tags.some(
|
||||
(tag) => typeof tag === "string" && tag === value
|
||||
)
|
||||
);
|
||||
case "project":
|
||||
if (value === null || value === "") {
|
||||
return !task.project;
|
||||
return !getEffectiveProject(task);
|
||||
}
|
||||
return task.project === value;
|
||||
return getEffectiveProject(task) === value;
|
||||
case "context":
|
||||
if (value === null || value === "") {
|
||||
return !task.context;
|
||||
return !task.metadata.context;
|
||||
}
|
||||
return task.context === value;
|
||||
return task.metadata.context === value;
|
||||
case "dueDate":
|
||||
case "scheduledDate":
|
||||
case "startDate":
|
||||
|
|
@ -1115,13 +1128,13 @@ export class KanbanComponent extends Component {
|
|||
let taskDate: number | undefined;
|
||||
switch (dateField) {
|
||||
case "dueDate":
|
||||
taskDate = task.dueDate;
|
||||
taskDate = task.metadata.dueDate;
|
||||
break;
|
||||
case "scheduledDate":
|
||||
taskDate = task.scheduledDate;
|
||||
taskDate = task.metadata.scheduledDate;
|
||||
break;
|
||||
case "startDate":
|
||||
taskDate = task.startDate;
|
||||
taskDate = task.metadata.startDate;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1274,14 +1287,17 @@ export class KanbanComponent extends Component {
|
|||
for (const column of kanbanConfig.customColumns) {
|
||||
if (column.value === "" || column.value === null) {
|
||||
// "No Tags" column
|
||||
if (!task.tags || task.tags.length === 0) {
|
||||
if (
|
||||
!task.metadata.tags ||
|
||||
task.metadata.tags.length === 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
// Specific tag column
|
||||
if (
|
||||
task.tags &&
|
||||
task.tags.some(
|
||||
task.metadata.tags &&
|
||||
task.metadata.tags.some(
|
||||
(tag) =>
|
||||
typeof tag === "string" &&
|
||||
tag === column.value
|
||||
|
|
@ -1293,28 +1309,31 @@ export class KanbanComponent extends Component {
|
|||
}
|
||||
} else {
|
||||
// Use default columns - find the first tag that matches existing columns
|
||||
if (!task.tags || task.tags.length === 0) {
|
||||
if (
|
||||
!task.metadata.tags ||
|
||||
task.metadata.tags.length === 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
// Return the first string tag (for simplicity, as we need to determine which column it came from)
|
||||
const firstStringTag = task.tags.find(
|
||||
const firstStringTag = task.metadata.tags.find(
|
||||
(tag) => typeof tag === "string"
|
||||
);
|
||||
return firstStringTag || "";
|
||||
}
|
||||
return "";
|
||||
case "project":
|
||||
return task.project || "";
|
||||
return getEffectiveProject(task) || "";
|
||||
case "context":
|
||||
return task.context || "";
|
||||
return task.metadata.context || "";
|
||||
case "priority":
|
||||
return task.priority || null;
|
||||
return task.metadata.priority || null;
|
||||
case "dueDate":
|
||||
return this.getDateCategory(task.dueDate);
|
||||
return this.getDateCategory(task.metadata.dueDate);
|
||||
case "scheduledDate":
|
||||
return this.getDateCategory(task.scheduledDate);
|
||||
return this.getDateCategory(task.metadata.scheduledDate);
|
||||
case "startDate":
|
||||
return this.getDateCategory(task.startDate);
|
||||
return this.getDateCategory(task.metadata.startDate);
|
||||
case "filePath":
|
||||
return task.filePath;
|
||||
default:
|
||||
|
|
|
|||
76
src/components/settings/AboutSettingsTab.ts
Normal file
76
src/components/settings/AboutSettingsTab.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { setIcon, Setting } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
export function renderAboutSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl).setName(t("About") + " Task Genius").setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Version"))
|
||||
.setDesc(`Task Genius v${settingTab.plugin.manifest.version}`);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Donate"))
|
||||
.setDesc(
|
||||
t(
|
||||
"If you like this plugin, consider donating to support continued development:"
|
||||
)
|
||||
)
|
||||
.addButton((bt) => {
|
||||
bt.buttonEl.outerHTML = `<a href="https://www.buymeacoffee.com/boninall"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=boninall&button_colour=6495ED&font_colour=ffffff&font_family=Inter&outline_colour=000000&coffee_colour=FFDD00"></a>`;
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Documentation"))
|
||||
.setDesc(t("View the documentation for this plugin"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Open Documentation")).onClick(() => {
|
||||
window.open("https://taskgenius.md/docs/getting-started");
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Discord"))
|
||||
.setDesc(t("Chat with us"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Open Discord")).onClick(() => {
|
||||
window.open("https://discord.gg/ARR2rHHX6b");
|
||||
});
|
||||
});
|
||||
|
||||
const descFragment = document.createDocumentFragment();
|
||||
descFragment.createEl("span", {
|
||||
cls: "tg-icons-desc",
|
||||
}, (el) => {
|
||||
el.setText(t("Task Genius icons are designed by"))
|
||||
});
|
||||
descFragment.createEl("a", {
|
||||
href: "https://github.com/jsmorabito",
|
||||
attr: {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
}, (el) => {
|
||||
el.setText(" @Jsmorabito");
|
||||
});
|
||||
|
||||
// Task Genius Icons Settings
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task Genius Icons"))
|
||||
.setDesc(descFragment)
|
||||
.setHeading();
|
||||
|
||||
containerEl.createDiv({
|
||||
cls: "tg-icons-container",
|
||||
}, (el) => {
|
||||
for(const status of Object.keys(settingTab.plugin.settings.taskStatuses)) {
|
||||
const iconEl = el.createSpan();
|
||||
setIcon(iconEl, status as "notStarted" | "inProgress" | "completed" | "abandoned" | "planned")
|
||||
}
|
||||
const tgIconEl = el.createSpan();
|
||||
setIcon(tgIconEl, "task-genius")
|
||||
})
|
||||
}
|
||||
145
src/components/settings/BetaTestSettingsTab.ts
Normal file
145
src/components/settings/BetaTestSettingsTab.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Setting } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
export function renderBetaTestSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Beta Test Features"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Experimental features that are currently in testing phase. These features may be unstable and could change or be removed in future updates."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
// Warning banner
|
||||
const warningBanner = containerEl.createDiv({
|
||||
cls: "beta-test-warning-banner",
|
||||
});
|
||||
|
||||
warningBanner.createEl("div", {
|
||||
cls: "beta-warning-icon",
|
||||
text: "⚠️",
|
||||
});
|
||||
|
||||
const warningContent = warningBanner.createDiv({
|
||||
cls: "beta-warning-content",
|
||||
});
|
||||
|
||||
warningContent.createEl("div", {
|
||||
cls: "beta-warning-title",
|
||||
text: t("Beta Features Warning"),
|
||||
});
|
||||
|
||||
const warningText = warningContent.createEl("div", {
|
||||
cls: "beta-warning-text",
|
||||
text: t(
|
||||
"These features are experimental and may be unstable. They could change significantly or be removed in future updates due to Obsidian API changes or other factors. Please use with caution and provide feedback to help improve these features."
|
||||
),
|
||||
});
|
||||
|
||||
// Base View Settings
|
||||
new Setting(containerEl)
|
||||
.setName(t("Base View"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Advanced view management features that extend the default Task Genius views with additional functionality."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
const descFragment = new DocumentFragment();
|
||||
descFragment.createEl("span", {
|
||||
text: t(
|
||||
"Enable experimental Base View functionality. This feature provides enhanced view management capabilities but may be affected by future Obsidian API changes. You may need to restart Obsidian to see the changes."
|
||||
),
|
||||
});
|
||||
|
||||
descFragment.createEl("div", {
|
||||
text: t(
|
||||
"You need to close all bases view if you already create task view in them and remove unused view via edit them manually when disable this feature."
|
||||
),
|
||||
cls: "mod-warning",
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable Base View"))
|
||||
.setDesc(descFragment)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.betaTest?.enableBaseView || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (value) {
|
||||
new ConfirmModal(settingTab.plugin, {
|
||||
title: t("Enable Base View"),
|
||||
message: t(
|
||||
"Enable experimental Base View functionality. This feature provides enhanced view management capabilities but may be affected by future Obsidian API changes."
|
||||
),
|
||||
confirmText: t("Enable"),
|
||||
cancelText: t("Cancel"),
|
||||
onConfirm: (confirmed: boolean) => {
|
||||
if (!confirmed) {
|
||||
setTimeout(() => {
|
||||
toggle.setValue(false);
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settingTab.plugin.settings.betaTest) {
|
||||
settingTab.plugin.settings.betaTest = {
|
||||
enableBaseView: false,
|
||||
};
|
||||
}
|
||||
settingTab.plugin.settings.betaTest.enableBaseView =
|
||||
confirmed;
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
},
|
||||
}).open();
|
||||
} else {
|
||||
if (settingTab.plugin.settings.betaTest) {
|
||||
settingTab.plugin.settings.betaTest.enableBaseView =
|
||||
false;
|
||||
}
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Feedback section
|
||||
new Setting(containerEl)
|
||||
.setName(t("Beta Feedback"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Help improve these features by providing feedback on your experience."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Report Issues"))
|
||||
.setDesc(
|
||||
t(
|
||||
"If you encounter any issues with beta features, please report them to help improve the plugin."
|
||||
)
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Report Issue")).onClick(() => {
|
||||
window.open(
|
||||
"https://github.com/quorafind/obsidian-task-genius/issues"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
89
src/components/settings/DatePrioritySettingsTab.ts
Normal file
89
src/components/settings/DatePrioritySettingsTab.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Setting } from "obsidian";
|
||||
import { t } from "../../translations/helper";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
|
||||
export function renderDatePrioritySettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Priority Picker Settings"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle to enable priority picker dropdown for emoji and letter format priorities."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable priority picker"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle to enable priority picker dropdown for emoji and letter format priorities."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.enablePriorityPicker)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.enablePriorityPicker = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable priority keyboard shortcuts"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle to enable keyboard shortcuts for setting task priorities."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.enablePriorityKeyboardShortcuts
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.enablePriorityKeyboardShortcuts =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// Date picker settings
|
||||
new Setting(containerEl).setName(t("Date picker")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable date picker"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable date picker for tasks. This will add a calendar icon near your tasks which you can click to select a date."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.enableDatePicker)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.enableDatePicker = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// Recurrence date base setting
|
||||
new Setting(containerEl)
|
||||
.setName(t("Recurrence date calculation"))
|
||||
.setDesc(t("Choose how to calculate the next date for recurring tasks"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("due", t("Based on due date"))
|
||||
.addOption("scheduled", t("Based on scheduled date"))
|
||||
.addOption("current", t("Based on current date"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.recurrenceDateBase || "due"
|
||||
)
|
||||
.onChange(async (value: "due" | "scheduled" | "current") => {
|
||||
settingTab.plugin.settings.recurrenceDateBase = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
42
src/components/settings/HabitSettingsTab.ts
Normal file
42
src/components/settings/HabitSettingsTab.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Setting } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
import { HabitList } from "../HabitSettingList";
|
||||
|
||||
export function renderHabitSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Habit"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Configure habit settings, including adding new habits, editing existing habits, and managing habit completion."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl).setName(t("Enable habits")).addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.habit.enableHabits)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.habit.enableHabits = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
const habitContainer = containerEl.createDiv({
|
||||
cls: "habit-settings-container",
|
||||
});
|
||||
|
||||
// Habit List
|
||||
displayHabitList(settingTab, habitContainer);
|
||||
}
|
||||
|
||||
function displayHabitList(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
): void {
|
||||
// 创建习惯列表组件
|
||||
new HabitList(settingTab.plugin, containerEl);
|
||||
}
|
||||
1529
src/components/settings/IcsSettingsTab.ts
Normal file
1529
src/components/settings/IcsSettingsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
741
src/components/settings/ProgressSettingsTab.ts
Normal file
741
src/components/settings/ProgressSettingsTab.ts
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
import { Setting, TextAreaComponent } from "obsidian";
|
||||
import { DEFAULT_SETTINGS } from "../../common/setting-definition";
|
||||
import { t } from "../../translations/helper";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { formatProgressText } from "../../editor-ext/progressBarWidget";
|
||||
|
||||
export function renderProgressSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Progress bar"))
|
||||
.setDesc(
|
||||
t(
|
||||
"You can customize the progress bar behind the parent task(usually at the end of the task). You can also customize the progress bar for the task below the heading."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Progress display mode"))
|
||||
.setDesc(t("Choose how to display task progress"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("none", t("No progress indicators"))
|
||||
.addOption("graphical", t("Graphical progress bar"))
|
||||
.addOption("text", t("Text progress indicator"))
|
||||
.addOption("both", t("Both graphical and text"))
|
||||
.setValue(settingTab.plugin.settings.progressBarDisplayMode)
|
||||
.onChange(async (value: any) => {
|
||||
settingTab.plugin.settings.progressBarDisplayMode = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
})
|
||||
);
|
||||
|
||||
// Only show these options if some form of progress bar is enabled
|
||||
if (settingTab.plugin.settings.progressBarDisplayMode !== "none") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable progress bar in reading mode"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow this plugin to show progress bars in reading mode."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings
|
||||
.enableProgressbarInReadingMode
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.enableProgressbarInReadingMode =
|
||||
value;
|
||||
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Support hover to show progress info"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow this plugin to show progress info when hovering over the progress bar."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings
|
||||
.supportHoverToShowProgressInfo
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.supportHoverToShowProgressInfo =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Add progress bar to non-task bullet"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow adding progress bars to regular list items (non-task bullets)."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.addProgressBarToNonTaskBullet
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.addProgressBarToNonTaskBullet =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Add progress bar to Heading"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow this plugin to add progress bar for Task below the headings."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.addTaskProgressBarToHeading
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.addTaskProgressBarToHeading =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Count sub children of current Task"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow this plugin to count sub tasks when generating progress bar."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.countSubLevel)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.countSubLevel = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Use custom goal for progress bar"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to allow this plugin to find the pattern g::number as goal of the parent task."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.allowCustomProgressGoal
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.allowCustomProgressGoal =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// Only show the number settings for modes that include text display
|
||||
if (
|
||||
settingTab.plugin.settings.progressBarDisplayMode === "text" ||
|
||||
settingTab.plugin.settings.progressBarDisplayMode === "both"
|
||||
) {
|
||||
displayNumberToProgressbar(settingTab, containerEl);
|
||||
}
|
||||
|
||||
new Setting(containerEl).setName(t("Hide progress bars")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Hide progress bars based on conditions"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable hiding progress bars based on tags, folders, or metadata."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings
|
||||
.hideProgressBarBasedOnConditions
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.hideProgressBarBasedOnConditions =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
if (settingTab.plugin.settings.hideProgressBarBasedOnConditions) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Hide by tags"))
|
||||
.setDesc(
|
||||
t(
|
||||
'Specify tags that will hide progress bars (comma-separated, without #). Example: "no-progress-bar,hide-progress"'
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.hideProgressBarTags)
|
||||
.setValue(
|
||||
settingTab.plugin.settings.hideProgressBarTags
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.hideProgressBarTags =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Hide by folders"))
|
||||
.setDesc(
|
||||
t(
|
||||
'Specify folder paths that will hide progress bars (comma-separated). Example: "Daily Notes,Projects/Hidden"'
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("folder1,folder2/subfolder")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.hideProgressBarFolders
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.hideProgressBarFolders =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Hide by metadata"))
|
||||
.setDesc(
|
||||
t(
|
||||
'Specify frontmatter metadata that will hide progress bars. Example: "hide-progress-bar: true"'
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(
|
||||
DEFAULT_SETTINGS.hideProgressBarMetadata
|
||||
)
|
||||
.setValue(
|
||||
settingTab.plugin.settings.hideProgressBarMetadata
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.hideProgressBarMetadata =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Show progress bars based on heading"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable showing progress bars based on heading."
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("# heading"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings
|
||||
.showProgressBarBasedOnHeading
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.showProgressBarBasedOnHeading =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayNumberToProgressbar(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
): void {
|
||||
// Add setting for display mode
|
||||
new Setting(containerEl)
|
||||
.setName(t("Progress format"))
|
||||
.setDesc(t("Choose how to display the task progress"))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("percentage", t("Percentage (75%)"))
|
||||
.addOption(
|
||||
"bracketPercentage",
|
||||
t("Bracketed percentage ([75%])")
|
||||
)
|
||||
.addOption("fraction", t("Fraction (3/4)"))
|
||||
.addOption("bracketFraction", t("Bracketed fraction ([3/4])"))
|
||||
.addOption("detailed", t("Detailed ([3✓ 1⟳ 0✗ 1? / 5])"))
|
||||
.addOption("custom", t("Custom format"))
|
||||
.addOption("range-based", t("Range-based text"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.displayMode || "bracketFraction"
|
||||
)
|
||||
.onChange(async (value: any) => {
|
||||
settingTab.plugin.settings.displayMode = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Show custom format setting only when custom format is selected
|
||||
if (settingTab.plugin.settings.displayMode === "custom") {
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.createEl("div", {
|
||||
cls: "custom-format-placeholder-info",
|
||||
text: t(
|
||||
"Use placeholders like {{COMPLETED}}, {{TOTAL}}, {{PERCENT}}, etc."
|
||||
),
|
||||
});
|
||||
|
||||
fragment.createEl("div", {
|
||||
cls: "custom-format-placeholder-info",
|
||||
text: t(
|
||||
"Available placeholders: {{COMPLETED}}, {{TOTAL}}, {{IN_PROGRESS}}, {{ABANDONED}}, {{PLANNED}}, {{NOT_STARTED}}, {{PERCENT}}, {{COMPLETED_SYMBOL}}, {{IN_PROGRESS_SYMBOL}}, {{ABANDONED_SYMBOL}}, {{PLANNED_SYMBOL}}"
|
||||
),
|
||||
});
|
||||
|
||||
fragment.createEl("div", {
|
||||
cls: "custom-format-placeholder-info",
|
||||
text: t(
|
||||
"Support expression in format, like using data.percentages to get the percentage of completed tasks. And using math or even repeat functions to get the result."
|
||||
),
|
||||
});
|
||||
|
||||
new Setting(containerEl).setName(t("Custom format")).setDesc(fragment);
|
||||
|
||||
const previewEl = containerEl.createDiv({
|
||||
cls: "custom-format-preview-container",
|
||||
});
|
||||
|
||||
const previewLabel = previewEl.createDiv({
|
||||
cls: "custom-format-preview-label",
|
||||
text: t("Preview:"),
|
||||
});
|
||||
|
||||
const previewContent = previewEl.createDiv({
|
||||
cls: "custom-format-preview-content",
|
||||
});
|
||||
|
||||
// 初始预览
|
||||
updateFormatPreview(
|
||||
settingTab,
|
||||
containerEl,
|
||||
settingTab.plugin.settings.customFormat ||
|
||||
"[{{COMPLETED}}/{{TOTAL}}]"
|
||||
);
|
||||
|
||||
const textarea = containerEl.createEl(
|
||||
"div",
|
||||
{
|
||||
cls: "custom-format-textarea-container",
|
||||
},
|
||||
(el) => {
|
||||
const textAreaComponent = new TextAreaComponent(el);
|
||||
textAreaComponent.inputEl.toggleClass(
|
||||
"custom-format-textarea",
|
||||
true
|
||||
);
|
||||
textAreaComponent
|
||||
.setPlaceholder("[{{COMPLETED}}/{{TOTAL}}]")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.customFormat ||
|
||||
"[{{COMPLETED}}/{{TOTAL}}]"
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.customFormat = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
// 更新预览
|
||||
updateFormatPreview(settingTab, containerEl, value);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 添加预览区域
|
||||
|
||||
// Show examples of advanced formats using expressions
|
||||
new Setting(containerEl)
|
||||
.setName(t("Expression examples"))
|
||||
.setDesc(t("Examples of advanced formats using expressions"))
|
||||
.setHeading();
|
||||
|
||||
const exampleContainer = containerEl.createEl("div", {
|
||||
cls: "expression-examples",
|
||||
});
|
||||
|
||||
const examples = [
|
||||
{
|
||||
name: t("Text Progress Bar"),
|
||||
code: '[${="=".repeat(Math.floor(data.percentages.completed/10)) + " ".repeat(10-Math.floor(data.percentages.completed/10))}] {{PERCENT}}%',
|
||||
},
|
||||
{
|
||||
name: t("Emoji Progress Bar"),
|
||||
code: '${="⬛".repeat(Math.floor(data.percentages.completed/10)) + "⬜".repeat(10-Math.floor(data.percentages.completed/10))} {{PERCENT}}%',
|
||||
},
|
||||
{
|
||||
name: t("Color-coded Status"),
|
||||
code: "{{COMPLETED}}/{{TOTAL}} ${=data.percentages.completed < 30 ? '🔴' : data.percentages.completed < 70 ? '🟠' : '🟢'}",
|
||||
},
|
||||
{
|
||||
name: t("Status with Icons"),
|
||||
code: "[{{COMPLETED_SYMBOL}}:{{COMPLETED}} {{IN_PROGRESS_SYMBOL}}:{{IN_PROGRESS}} {{PLANNED_SYMBOL}}:{{PLANNED}} / {{TOTAL}}]",
|
||||
},
|
||||
];
|
||||
|
||||
examples.forEach((example) => {
|
||||
const exampleItem = exampleContainer.createEl("div", {
|
||||
cls: "expression-example-item",
|
||||
});
|
||||
|
||||
exampleItem.createEl("div", {
|
||||
cls: "expression-example-name",
|
||||
text: example.name,
|
||||
});
|
||||
|
||||
const codeEl = exampleItem.createEl("code", {
|
||||
cls: "expression-example-code",
|
||||
text: example.code,
|
||||
});
|
||||
|
||||
// 添加预览效果
|
||||
const previewEl = exampleItem.createEl("div", {
|
||||
cls: "expression-example-preview",
|
||||
});
|
||||
|
||||
// 创建示例数据来渲染预览
|
||||
const sampleData = {
|
||||
completed: 3,
|
||||
total: 5,
|
||||
inProgress: 1,
|
||||
abandoned: 0,
|
||||
notStarted: 0,
|
||||
planned: 1,
|
||||
percentages: {
|
||||
completed: 60,
|
||||
inProgress: 20,
|
||||
abandoned: 0,
|
||||
notStarted: 0,
|
||||
planned: 20,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const renderedText = renderFormatPreview(
|
||||
settingTab,
|
||||
example.code,
|
||||
sampleData
|
||||
);
|
||||
previewEl.setText(`${t("Preview")}: ${renderedText}`);
|
||||
} catch (error) {
|
||||
previewEl.setText(`${t("Preview")}: Error`);
|
||||
previewEl.addClass("expression-preview-error");
|
||||
}
|
||||
|
||||
const useButton = exampleItem.createEl("button", {
|
||||
cls: "expression-example-use",
|
||||
text: t("Use"),
|
||||
});
|
||||
|
||||
useButton.addEventListener("click", () => {
|
||||
settingTab.plugin.settings.customFormat = example.code;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
const inputs = containerEl.querySelectorAll("textarea");
|
||||
for (const input of Array.from(inputs)) {
|
||||
if (input.placeholder === "[{{COMPLETED}}/{{TOTAL}}]") {
|
||||
input.value = example.code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateFormatPreview(settingTab, containerEl, example.code);
|
||||
});
|
||||
});
|
||||
}
|
||||
// Only show legacy percentage toggle for range-based or when displayMode is not set
|
||||
else if (
|
||||
settingTab.plugin.settings.displayMode === "range-based" ||
|
||||
!settingTab.plugin.settings.displayMode
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Show percentage"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to show percentage instead of completed/total count."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.showPercentage)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.showPercentage = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// If percentage display and range-based mode is selected
|
||||
if (
|
||||
settingTab.plugin.settings.showPercentage &&
|
||||
settingTab.plugin.settings.displayMode === "range-based"
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Customize progress ranges"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to customize the text for different progress ranges."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.customizeProgressRanges
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.customizeProgressRanges =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
})
|
||||
);
|
||||
|
||||
if (settingTab.plugin.settings.customizeProgressRanges) {
|
||||
addProgressRangesSettings(settingTab, containerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addProgressRangesSettings(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Progress Ranges"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Define progress ranges and their corresponding text representations."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
// Display existing ranges
|
||||
settingTab.plugin.settings.progressRanges.forEach((range, index) => {
|
||||
new Setting(containerEl)
|
||||
.setName(`${t("Range")} ${index + 1}: ${range.min}%-${range.max}%`)
|
||||
.setDesc(
|
||||
`${t("Use")} {{PROGRESS}} ${t(
|
||||
"as a placeholder for the percentage value"
|
||||
)}`
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(
|
||||
`${t("Template text with")} {{PROGRESS}} ${t(
|
||||
"placeholder"
|
||||
)}`
|
||||
)
|
||||
.setValue(range.text)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.progressRanges[index].text =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Delete").onClick(async () => {
|
||||
settingTab.plugin.settings.progressRanges.splice(index, 1);
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Add new range"))
|
||||
.setDesc(t("Add a new progress percentage range with custom text"));
|
||||
|
||||
// Add a new range
|
||||
const newRangeSetting = new Setting(containerEl);
|
||||
newRangeSetting.infoEl.detach();
|
||||
|
||||
newRangeSetting
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("Min percentage (0-100)"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// This will be handled when the user clicks the Add button
|
||||
})
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("Max percentage (0-100)"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// This will be handled when the user clicks the Add button
|
||||
})
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("Text template (use {{PROGRESS}})"))
|
||||
.setValue("")
|
||||
.onChange(async (value) => {
|
||||
// This will be handled when the user clicks the Add button
|
||||
})
|
||||
)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Add").onClick(async () => {
|
||||
const settingsContainer = button.buttonEl.parentElement;
|
||||
if (!settingsContainer) return;
|
||||
|
||||
const inputs = settingsContainer.querySelectorAll("input");
|
||||
if (inputs.length < 3) return;
|
||||
|
||||
const min = parseInt(inputs[0].value);
|
||||
const max = parseInt(inputs[1].value);
|
||||
const text = inputs[2].value;
|
||||
|
||||
if (isNaN(min) || isNaN(max) || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingTab.plugin.settings.progressRanges.push({
|
||||
min,
|
||||
max,
|
||||
text,
|
||||
});
|
||||
|
||||
// Clear inputs
|
||||
inputs[0].value = "";
|
||||
inputs[1].value = "";
|
||||
inputs[2].value = "";
|
||||
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
});
|
||||
});
|
||||
|
||||
// Reset to defaults
|
||||
new Setting(containerEl)
|
||||
.setName(t("Reset to defaults"))
|
||||
.setDesc(t("Reset progress ranges to default values"))
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Reset")).onClick(async () => {
|
||||
settingTab.plugin.settings.progressRanges = [
|
||||
{
|
||||
min: 0,
|
||||
max: 20,
|
||||
text: t("Just started {{PROGRESS}}%"),
|
||||
},
|
||||
{
|
||||
min: 20,
|
||||
max: 40,
|
||||
text: t("Making progress {{PROGRESS}}%"),
|
||||
},
|
||||
{ min: 40, max: 60, text: t("Half way {{PROGRESS}}%") },
|
||||
{
|
||||
min: 60,
|
||||
max: 80,
|
||||
text: t("Good progress {{PROGRESS}}%"),
|
||||
},
|
||||
{
|
||||
min: 80,
|
||||
max: 100,
|
||||
text: t("Almost there {{PROGRESS}}%"),
|
||||
},
|
||||
];
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateFormatPreview(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement,
|
||||
formatText: string
|
||||
): void {
|
||||
const previewContainer = containerEl.querySelector(
|
||||
".custom-format-preview-content"
|
||||
);
|
||||
if (!previewContainer) return;
|
||||
|
||||
// 创建示例数据
|
||||
const sampleData = {
|
||||
completed: 3,
|
||||
total: 5,
|
||||
inProgress: 1,
|
||||
abandoned: 0,
|
||||
notStarted: 0,
|
||||
planned: 1,
|
||||
percentages: {
|
||||
completed: 60,
|
||||
inProgress: 20,
|
||||
abandoned: 0,
|
||||
notStarted: 0,
|
||||
planned: 20,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const renderedText = renderFormatPreview(
|
||||
settingTab,
|
||||
formatText,
|
||||
sampleData
|
||||
);
|
||||
previewContainer.setText(renderedText);
|
||||
previewContainer.removeClass("custom-format-preview-error");
|
||||
} catch (error) {
|
||||
previewContainer.setText("Error rendering format");
|
||||
previewContainer.addClass("custom-format-preview-error");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加渲染格式文本的辅助方法
|
||||
function renderFormatPreview(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
formatText: string,
|
||||
sampleData: any
|
||||
): string {
|
||||
try {
|
||||
// 保存原始的customFormat值
|
||||
const originalFormat = settingTab.plugin.settings.customFormat;
|
||||
|
||||
// 临时设置customFormat为我们要预览的格式
|
||||
settingTab.plugin.settings.customFormat = formatText;
|
||||
|
||||
// 使用插件的formatProgressText函数计算预览
|
||||
const result = formatProgressText(sampleData, settingTab.plugin);
|
||||
|
||||
// 恢复原始的customFormat值
|
||||
settingTab.plugin.settings.customFormat = originalFormat;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error in renderFormatPreview:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
726
src/components/settings/ProjectSettingsTab.ts
Normal file
726
src/components/settings/ProjectSettingsTab.ts
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
import { App, Modal, Setting } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
import TaskProgressBarPlugin from "../../index";
|
||||
|
||||
export function renderProjectSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enhanced Project Configuration"))
|
||||
.setDesc(
|
||||
t("Configure advanced project detection and management features")
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable enhanced project features"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enable path-based, metadata-based, and config file-based project detection"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.enableEnhancedProject || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (!settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig = {
|
||||
enableEnhancedProject: false,
|
||||
pathMappings: [],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
inheritFromFrontmatter: true,
|
||||
inheritFromFrontmatterForSubtasks: false,
|
||||
enabled: false,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "project.md",
|
||||
searchRecursively: true,
|
||||
enabled: false,
|
||||
},
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
settingTab.plugin.settings.projectConfig.enableEnhancedProject =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.projectConfig?.enableEnhancedProject) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Path-based Project Mappings"))
|
||||
.setDesc(t("Configure project names based on file paths"))
|
||||
.setHeading();
|
||||
|
||||
const pathMappingsContainer = containerEl.createDiv({
|
||||
cls: "project-path-mappings-container",
|
||||
});
|
||||
|
||||
const refreshPathMappings = () => {
|
||||
pathMappingsContainer.empty();
|
||||
|
||||
// Ensure pathMappings is always an array
|
||||
if (!settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig = {
|
||||
enableEnhancedProject: false,
|
||||
pathMappings: [],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
inheritFromFrontmatter: true,
|
||||
inheritFromFrontmatterForSubtasks: false,
|
||||
enabled: false,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "project.md",
|
||||
searchRecursively: true,
|
||||
enabled: false,
|
||||
},
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!settingTab.plugin.settings.projectConfig.pathMappings ||
|
||||
!Array.isArray(
|
||||
settingTab.plugin.settings.projectConfig.pathMappings
|
||||
)
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings = [];
|
||||
}
|
||||
|
||||
const pathMappings =
|
||||
settingTab.plugin.settings.projectConfig?.pathMappings || [];
|
||||
|
||||
if (pathMappings.length === 0) {
|
||||
pathMappingsContainer.createDiv({
|
||||
cls: "no-mappings-message",
|
||||
text: t("No path mappings configured yet."),
|
||||
});
|
||||
}
|
||||
|
||||
pathMappings.forEach((mapping, index) => {
|
||||
const mappingRow = pathMappingsContainer.createDiv({
|
||||
cls: "project-path-mapping-row",
|
||||
});
|
||||
|
||||
new Setting(mappingRow)
|
||||
.setName(`${t("Mapping")} ${index + 1}`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(
|
||||
t("Path pattern (e.g., Projects/Work)")
|
||||
)
|
||||
.setValue(mapping.pathPattern)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings[
|
||||
index
|
||||
].pathPattern = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t("Project name"))
|
||||
.setValue(mapping.projectName)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings[
|
||||
index
|
||||
].projectName = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setTooltip(t("Enabled"))
|
||||
.setValue(mapping.enabled)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings[
|
||||
index
|
||||
].enabled = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove"))
|
||||
.onClick(async () => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
await settingTab.plugin.saveSettings();
|
||||
refreshPathMappings();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add new mapping button
|
||||
new Setting(pathMappingsContainer).addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add Path Mapping"))
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
// Ensure projectConfig exists
|
||||
if (!settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig = {
|
||||
enableEnhancedProject: true,
|
||||
pathMappings: [],
|
||||
metadataConfig: {
|
||||
metadataKey: "project",
|
||||
inheritFromFrontmatter: true,
|
||||
inheritFromFrontmatterForSubtasks: false,
|
||||
enabled: false,
|
||||
},
|
||||
configFile: {
|
||||
fileName: "project.md",
|
||||
searchRecursively: true,
|
||||
enabled: false,
|
||||
},
|
||||
metadataMappings: [],
|
||||
defaultProjectNaming: {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure pathMappings is an array
|
||||
if (
|
||||
!Array.isArray(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
.pathMappings
|
||||
)
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.pathMappings =
|
||||
[];
|
||||
}
|
||||
|
||||
// Add new mapping
|
||||
settingTab.plugin.settings.projectConfig.pathMappings.push(
|
||||
{
|
||||
pathPattern: "",
|
||||
projectName: "",
|
||||
enabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
await settingTab.plugin.saveSettings();
|
||||
setTimeout(() => {
|
||||
refreshPathMappings();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refreshPathMappings();
|
||||
|
||||
// Metadata-based project configuration
|
||||
new Setting(containerEl)
|
||||
.setName(t("Metadata-based Project Configuration"))
|
||||
.setDesc(t("Configure project detection from file frontmatter"))
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable metadata project detection"))
|
||||
.setDesc(t("Detect project from file frontmatter metadata"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.metadataConfig
|
||||
?.enabled || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.metadataConfig
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.metadataConfig.enabled =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Metadata key"))
|
||||
.setDesc(t("The frontmatter key to use for project name"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("project")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.metadataConfig
|
||||
?.metadataKey || "project"
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.metadataConfig
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.metadataConfig.metadataKey =
|
||||
value || "project";
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Inherit from frontmatter"))
|
||||
.setDesc(t("Inherit other metadata fields from file frontmatter"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.metadataConfig
|
||||
?.inheritFromFrontmatter || true
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.metadataConfig
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.metadataConfig.inheritFromFrontmatter =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Inherit from frontmatter for subtasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Allow subtasks to inherit metadata from file frontmatter. When disabled, only top-level tasks inherit file metadata."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.metadataConfig
|
||||
?.inheritFromFrontmatterForSubtasks || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.metadataConfig
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.metadataConfig.inheritFromFrontmatterForSubtasks =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Project config file settings
|
||||
new Setting(containerEl)
|
||||
.setName(t("Project Configuration File"))
|
||||
.setDesc(t("Configure project detection from project config files"))
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable config file project detection"))
|
||||
.setDesc(t("Detect project from project configuration files"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
?.enabled || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.configFile.enabled =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Config file name"))
|
||||
.setDesc(t("Name of the project configuration file"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("project.md")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
?.fileName || "project.md"
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.configFile.fileName =
|
||||
value || "project.md";
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Search recursively"))
|
||||
.setDesc(t("Search for config files in parent directories"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
?.searchRecursively || true
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig?.configFile
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.configFile.searchRecursively =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Metadata mappings section
|
||||
new Setting(containerEl)
|
||||
.setName(t("Metadata Mappings"))
|
||||
.setDesc(
|
||||
t("Configure how metadata fields are mapped and transformed")
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
const metadataMappingsContainer = containerEl.createDiv({
|
||||
cls: "project-metadata-mappings-container",
|
||||
});
|
||||
|
||||
const refreshMetadataMappings = () => {
|
||||
metadataMappingsContainer.empty();
|
||||
|
||||
// Ensure metadataMappings is always an array
|
||||
if (
|
||||
!settingTab.plugin.settings.projectConfig?.metadataMappings ||
|
||||
!Array.isArray(
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings
|
||||
)
|
||||
) {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings =
|
||||
[];
|
||||
}
|
||||
}
|
||||
|
||||
const metadataMappings =
|
||||
settingTab.plugin.settings.projectConfig?.metadataMappings ||
|
||||
[];
|
||||
|
||||
if (metadataMappings.length === 0) {
|
||||
metadataMappingsContainer.createDiv({
|
||||
cls: "no-mappings-message",
|
||||
text: t("No metadata mappings configured yet."),
|
||||
});
|
||||
}
|
||||
|
||||
metadataMappings.forEach((mapping, index) => {
|
||||
const mappingRow = metadataMappingsContainer.createDiv({
|
||||
cls: "project-metadata-mapping-row",
|
||||
});
|
||||
|
||||
// Get already used target keys to avoid duplicates
|
||||
const usedTargetKeys = new Set(
|
||||
metadataMappings
|
||||
.filter((_, i) => i !== index)
|
||||
.map((m) => m.targetKey)
|
||||
.filter((key) => key && key.trim() !== "")
|
||||
);
|
||||
|
||||
// Available target keys from StandardTaskMetadata
|
||||
const availableTargetKeys = [
|
||||
"project",
|
||||
"context",
|
||||
"priority",
|
||||
"tags",
|
||||
"startDate",
|
||||
"scheduledDate",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"createdDate",
|
||||
"recurrence",
|
||||
].filter(
|
||||
(key) =>
|
||||
!usedTargetKeys.has(key) || key === mapping.targetKey
|
||||
);
|
||||
|
||||
new Setting(mappingRow)
|
||||
.setName(`${t("Mapping")} ${index + 1}`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t("Source key (e.g., proj)"))
|
||||
.setValue(mapping.sourceKey)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings[
|
||||
index
|
||||
].sourceKey = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addDropdown((dropdown) => {
|
||||
// Add empty option
|
||||
dropdown.addOption("", t("Select target field"));
|
||||
|
||||
// Add available options
|
||||
availableTargetKeys.forEach((key) => {
|
||||
dropdown.addOption(key, key);
|
||||
});
|
||||
|
||||
dropdown
|
||||
.setValue(mapping.targetKey)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings[
|
||||
index
|
||||
].targetKey = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
// Refresh to update available options for other dropdowns
|
||||
refreshMetadataMappings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setTooltip(t("Enabled"))
|
||||
.setValue(mapping.enabled)
|
||||
.onChange(async (value) => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings[
|
||||
index
|
||||
].enabled = value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove"))
|
||||
.onClick(async () => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
await settingTab.plugin.saveSettings();
|
||||
refreshMetadataMappings();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add new mapping button
|
||||
new Setting(metadataMappingsContainer).addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add Metadata Mapping"))
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
if (settingTab.plugin.settings.projectConfig) {
|
||||
if (
|
||||
!Array.isArray(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
.metadataMappings
|
||||
)
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings =
|
||||
[];
|
||||
}
|
||||
|
||||
settingTab.plugin.settings.projectConfig.metadataMappings.push(
|
||||
{
|
||||
sourceKey: "",
|
||||
targetKey: "",
|
||||
enabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
await settingTab.plugin.saveSettings();
|
||||
setTimeout(() => {
|
||||
refreshMetadataMappings();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refreshMetadataMappings();
|
||||
|
||||
// Default project naming section
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default Project Naming"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Configure fallback project naming when no explicit project is found"
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable default project naming"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use default naming strategy when no project is explicitly defined"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming?.enabled || false
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming.enabled =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!settingTab.plugin.settings.projectConfig?.defaultProjectNaming) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming = {
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Naming strategy"))
|
||||
.setDesc(t("Strategy for generating default project names"))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("filename", t("Use filename"))
|
||||
.addOption("foldername", t("Use folder name"))
|
||||
.addOption("metadata", t("Use metadata field"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming?.strategy || "filename"
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
!settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming =
|
||||
{
|
||||
strategy: "filename",
|
||||
stripExtension: true,
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming.strategy =
|
||||
value as "filename" | "foldername" | "metadata";
|
||||
await settingTab.plugin.saveSettings();
|
||||
// Refresh to show/hide metadata key field
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
settingTab.plugin.settings.projectConfig?.defaultProjectNaming
|
||||
?.strategy
|
||||
);
|
||||
|
||||
// Show metadata key field only for metadata strategy
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig?.defaultProjectNaming
|
||||
?.strategy === "metadata"
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Metadata key"))
|
||||
.setDesc(t("Metadata field to use as project name"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(
|
||||
t("Enter metadata key (e.g., project-name)")
|
||||
)
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming?.metadataKey || ""
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming.metadataKey =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show strip extension option only for filename strategy
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig?.defaultProjectNaming
|
||||
?.strategy === "filename"
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Strip file extension"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Remove file extension from filename when using as project name"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming?.stripExtension || true
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (
|
||||
settingTab.plugin.settings.projectConfig
|
||||
?.defaultProjectNaming
|
||||
) {
|
||||
settingTab.plugin.settings.projectConfig.defaultProjectNaming.stripExtension =
|
||||
value;
|
||||
await settingTab.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/components/settings/QuickCaptureSettingsTab.ts
Normal file
222
src/components/settings/QuickCaptureSettingsTab.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { Setting, Notice, App } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
export function renderQuickCaptureSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl).setName(t("Quick capture")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable quick capture"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable Org-mode style quick capture panel. Press Alt+C to open the capture panel."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture.enableQuickCapture
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.enableQuickCapture =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
if (!settingTab.plugin.settings.quickCapture.enableQuickCapture) return;
|
||||
|
||||
// Target type selection
|
||||
new Setting(containerEl)
|
||||
.setName(t("Target type"))
|
||||
.setDesc(t("Choose whether to capture to a fixed file or daily note"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("fixed", t("Fixed file"))
|
||||
.addOption("daily-note", t("Daily note"))
|
||||
.setValue(settingTab.plugin.settings.quickCapture.targetType)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.targetType =
|
||||
value as "fixed" | "daily-note";
|
||||
settingTab.applySettingsUpdate();
|
||||
// Refresh the settings display to show/hide relevant options
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 100);
|
||||
})
|
||||
);
|
||||
|
||||
// Fixed file settings
|
||||
if (settingTab.plugin.settings.quickCapture.targetType === "fixed") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Target file"))
|
||||
.setDesc(
|
||||
t(
|
||||
"The file where captured text will be saved. You can include a path, e.g., 'folder/Quick Capture.md'. Supports date templates like {{DATE:YYYY-MM-DD}} or {{date:YYYY-MM-DD-HHmm}}"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture.targetFile
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.targetFile =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Daily note settings
|
||||
if (settingTab.plugin.settings.quickCapture.targetType === "daily-note") {
|
||||
// Sync with daily notes plugin button
|
||||
new Setting(containerEl)
|
||||
.setName(t("Sync with Daily Notes plugin"))
|
||||
.setDesc(
|
||||
t("Automatically sync settings from the Daily Notes plugin")
|
||||
)
|
||||
.addButton((button) =>
|
||||
button.setButtonText(t("Sync now")).onClick(async () => {
|
||||
try {
|
||||
// Get daily notes plugin settings
|
||||
const dailyNotesPlugin = (settingTab.app as any)
|
||||
.internalPlugins.plugins["daily-notes"];
|
||||
if (dailyNotesPlugin && dailyNotesPlugin.enabled) {
|
||||
const dailyNotesSettings =
|
||||
dailyNotesPlugin.instance?.options || {};
|
||||
|
||||
console.log(dailyNotesSettings);
|
||||
|
||||
settingTab.plugin.settings.quickCapture.dailyNoteSettings =
|
||||
{
|
||||
format:
|
||||
dailyNotesSettings.format ||
|
||||
"YYYY-MM-DD",
|
||||
folder: dailyNotesSettings.folder || "",
|
||||
template: dailyNotesSettings.template || "",
|
||||
};
|
||||
|
||||
await settingTab.plugin.saveSettings();
|
||||
|
||||
// Refresh the settings display
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
|
||||
new Notice(
|
||||
t("Daily notes settings synced successfully")
|
||||
);
|
||||
} else {
|
||||
new Notice(t("Daily Notes plugin is not enabled"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to sync daily notes settings:",
|
||||
error
|
||||
);
|
||||
new Notice(t("Failed to sync daily notes settings"));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Daily note format"))
|
||||
.setDesc(t("Date format for daily notes (e.g., YYYY-MM-DD)"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture
|
||||
.dailyNoteSettings?.format || "YYYY-MM-DD"
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.dailyNoteSettings.format =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Daily note folder"))
|
||||
.setDesc(t("Folder path for daily notes (leave empty for root)"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture
|
||||
.dailyNoteSettings?.folder || ""
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.dailyNoteSettings.folder =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Daily note template"))
|
||||
.setDesc(t("Template file path for new daily notes (optional)"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture
|
||||
.dailyNoteSettings?.template || ""
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.dailyNoteSettings.template =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Target heading setting (for both types)
|
||||
new Setting(containerEl)
|
||||
.setName(t("Target heading"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Optional heading to append content under (leave empty to append to file)"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(
|
||||
settingTab.plugin.settings.quickCapture.targetHeading || ""
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.targetHeading =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Placeholder text"))
|
||||
.setDesc(t("Placeholder text to display in the capture panel"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setValue(settingTab.plugin.settings.quickCapture.placeholder)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.placeholder = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Append to file"))
|
||||
.setDesc(t("How to add captured content to the target location"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("append", t("Append"))
|
||||
.addOption("prepend", t("Prepend"))
|
||||
.addOption("replace", t("Replace"))
|
||||
.setValue(settingTab.plugin.settings.quickCapture.appendToFile)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.quickCapture.appendToFile =
|
||||
value as "append" | "prepend" | "replace";
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
294
src/components/settings/RewardSettingsTab.ts
Normal file
294
src/components/settings/RewardSettingsTab.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { Setting, debounce, TextComponent, Notice } from "obsidian";
|
||||
import { OccurrenceLevel, RewardItem } from "src/common/setting-definition";
|
||||
import { TaskProgressBarSettingTab } from "src/setting";
|
||||
import { t } from "src/translations/manager";
|
||||
import { ImageSuggest } from "../AutoComplete";
|
||||
|
||||
export function renderRewardSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Rewards"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Configure rewards for completing tasks. Define items, their occurrence chances, and conditions."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
// --- Enable Rewards ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable rewards"))
|
||||
.setDesc(t("Toggle to enable or disable the reward system."))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.rewards.enableRewards)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.rewards.enableRewards = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
if (!settingTab.plugin.settings.rewards.enableRewards) {
|
||||
return; // Don't render the rest if rewards are disabled
|
||||
}
|
||||
|
||||
// --- Reward Display Type ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Reward display type"))
|
||||
.setDesc(t("Choose how rewards are displayed when earned."))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("modal", t("Modal dialog"))
|
||||
.addOption("notice", t("Notice (Auto-accept)"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.rewards.showRewardType || "modal"
|
||||
)
|
||||
.onChange(async (value: "modal" | "notice") => {
|
||||
settingTab.plugin.settings.rewards.showRewardType = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Occurrence Levels ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Occurrence levels"))
|
||||
.setDesc(
|
||||
t("Define different levels of reward rarity and their probability.")
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
const occurrenceLevelsContainer = containerEl.createDiv({
|
||||
cls: "rewards-levels-container",
|
||||
});
|
||||
|
||||
const debounceChanceUpdate = debounce(
|
||||
(
|
||||
text: TextComponent,
|
||||
level: OccurrenceLevel,
|
||||
value: string,
|
||||
index: number
|
||||
) => {
|
||||
const chance = parseInt(value, 10);
|
||||
if (!isNaN(chance) && chance >= 0 && chance <= 100) {
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels[
|
||||
index
|
||||
].chance = chance;
|
||||
settingTab.applySettingsUpdate();
|
||||
} else {
|
||||
// Optional: Provide feedback for invalid input
|
||||
new Notice(t("Chance must be between 0 and 100."));
|
||||
text.setValue(level.chance.toString()); // Revert
|
||||
}
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
const debounceNameUpdate = debounce((value: string, index: number) => {
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels[index].name =
|
||||
value.trim();
|
||||
settingTab.applySettingsUpdate();
|
||||
}, 1000);
|
||||
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels.forEach(
|
||||
(level, index) => {
|
||||
const levelSetting = new Setting(occurrenceLevelsContainer)
|
||||
.setClass("rewards-level-row")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("Level Name (e.g., common)"))
|
||||
.setValue(level.name)
|
||||
.onChange((value) => {
|
||||
debounceNameUpdate(value, index);
|
||||
})
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(t("Chance (%)"))
|
||||
.setValue(level.chance.toString())
|
||||
.onChange((value) => {
|
||||
debounceChanceUpdate(text, level, value, index);
|
||||
})
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Delete Level"))
|
||||
.setClass("mod-warning")
|
||||
.onClick(() => {
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
new Setting(occurrenceLevelsContainer).addButton((button) =>
|
||||
button
|
||||
.setButtonText(t("Add occurrence level"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
const newLevel: OccurrenceLevel = {
|
||||
name: t("New Level"),
|
||||
chance: 0,
|
||||
};
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels.push(
|
||||
newLevel
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
// --- Reward Items ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Reward items"))
|
||||
.setDesc(t("Manage the specific rewards that can be obtained."))
|
||||
.setHeading();
|
||||
|
||||
const rewardItemsContainer = containerEl.createDiv({
|
||||
cls: "rewards-items-container",
|
||||
});
|
||||
|
||||
// Get available occurrence level names for dropdown
|
||||
const levelNames = settingTab.plugin.settings.rewards.occurrenceLevels.map(
|
||||
(l) => l.name
|
||||
);
|
||||
if (levelNames.length === 0) levelNames.push(t("No levels defined"));
|
||||
|
||||
settingTab.plugin.settings.rewards.rewardItems.forEach((item, index) => {
|
||||
const itemSetting = new Setting(rewardItemsContainer)
|
||||
.setClass("rewards-item-row")
|
||||
.addTextArea((text) =>
|
||||
text // Use TextArea for potentially longer names
|
||||
.setPlaceholder(t("Reward Name/Text"))
|
||||
.setValue(item.name)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.rewards.rewardItems[
|
||||
index
|
||||
].name = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
levelNames.forEach((levelName) => {
|
||||
dropdown.addOption(levelName, levelName);
|
||||
});
|
||||
dropdown
|
||||
.setValue(item.occurrence || levelNames[0]) // Handle missing/default
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.rewards.rewardItems[
|
||||
index
|
||||
].occurrence = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
})
|
||||
.addText((text) => {
|
||||
text.inputEl.ariaLabel = t("Inventory (-1 for ∞)");
|
||||
text.setPlaceholder(t("Inventory (-1 for ∞)")) // For Inventory
|
||||
.setValue(item.inventory.toString())
|
||||
.onChange((value) => {
|
||||
const inventory = parseInt(value, 10);
|
||||
if (!isNaN(inventory)) {
|
||||
settingTab.plugin.settings.rewards.rewardItems[
|
||||
index
|
||||
].inventory = inventory;
|
||||
settingTab.applySettingsUpdate();
|
||||
} else {
|
||||
new Notice(t("Invalid inventory number."));
|
||||
text.setValue(item.inventory.toString()); // Revert
|
||||
}
|
||||
});
|
||||
})
|
||||
.addText((text) =>
|
||||
text // For Condition
|
||||
.setPlaceholder(t("Condition (e.g., #tag AND project)"))
|
||||
.setValue(item.condition || "")
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.rewards.rewardItems[
|
||||
index
|
||||
].condition = value.trim() || undefined; // Store as undefined if empty
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(t("Image url (optional)")) // For Image URL
|
||||
.setValue(item.imageUrl || "")
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.rewards.rewardItems[
|
||||
index
|
||||
].imageUrl = value.trim() || undefined; // Store as undefined if empty
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
|
||||
new ImageSuggest(
|
||||
settingTab.app,
|
||||
text.inputEl,
|
||||
settingTab.plugin
|
||||
);
|
||||
})
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Delete reward item"))
|
||||
.setClass("mod-warning")
|
||||
.onClick(() => {
|
||||
settingTab.plugin.settings.rewards.rewardItems.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
// Add some spacing or dividers if needed visually
|
||||
rewardItemsContainer.createEl("hr", {
|
||||
cls: "rewards-item-divider",
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.rewards.rewardItems.length === 0) {
|
||||
rewardItemsContainer.createEl("p", {
|
||||
text: t("No reward items defined yet."),
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(rewardItemsContainer).addButton((button) =>
|
||||
button
|
||||
.setButtonText(t("Add reward item"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
const newItem: RewardItem = {
|
||||
id: `reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 7)}`, // Simple unique ID
|
||||
name: t("New Reward"),
|
||||
occurrence:
|
||||
settingTab.plugin.settings.rewards.occurrenceLevels[0]
|
||||
?.name || "default", // Use first level or default
|
||||
inventory: -1, // Default to infinite
|
||||
};
|
||||
settingTab.plugin.settings.rewards.rewardItems.push(newItem);
|
||||
settingTab.applySettingsUpdate();
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
}
|
||||
396
src/components/settings/TaskFilterSettingsTab.ts
Normal file
396
src/components/settings/TaskFilterSettingsTab.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { App, Modal, Setting } from "obsidian";
|
||||
import { t } from "../../translations/helper";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { migrateOldFilterOptions } from "../../editor-ext/filterTasks";
|
||||
import { generateUniqueId } from "../../utils/common";
|
||||
|
||||
class PresetFilterModal extends Modal {
|
||||
constructor(app: App, private preset: any, private onSave: () => void) {
|
||||
super(app);
|
||||
// Migrate old preset options if needed
|
||||
if (this.preset && this.preset.options) {
|
||||
this.preset.options = migrateOldFilterOptions(this.preset.options);
|
||||
}
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// Set modal title
|
||||
this.titleEl.setText(t("Edit Filter: ") + this.preset.name);
|
||||
|
||||
// Create form for filter options
|
||||
new Setting(contentEl).setName(t("Filter name")).addText((text) => {
|
||||
text.setValue(this.preset.name).onChange((value) => {
|
||||
this.preset.name = value;
|
||||
});
|
||||
});
|
||||
|
||||
// Task status section
|
||||
new Setting(contentEl)
|
||||
.setName(t("Task Status"))
|
||||
.setDesc(t("Include or exclude tasks based on their status"));
|
||||
|
||||
const statusOptions = [
|
||||
{ id: "includeCompleted", name: t("Include Completed Tasks") },
|
||||
{ id: "includeInProgress", name: t("Include In Progress Tasks") },
|
||||
{ id: "includeAbandoned", name: t("Include Abandoned Tasks") },
|
||||
{ id: "includeNotStarted", name: t("Include Not Started Tasks") },
|
||||
{ id: "includePlanned", name: t("Include Planned Tasks") },
|
||||
];
|
||||
|
||||
for (const option of statusOptions) {
|
||||
new Setting(contentEl).setName(option.name).addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.preset.options[option.id])
|
||||
.onChange((value) => {
|
||||
this.preset.options[option.id] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Related tasks section
|
||||
new Setting(contentEl)
|
||||
.setName(t("Related Tasks"))
|
||||
.setDesc(
|
||||
t("Include parent, child, and sibling tasks in the filter")
|
||||
);
|
||||
|
||||
const relatedOptions = [
|
||||
{ id: "includeParentTasks", name: t("Include Parent Tasks") },
|
||||
{ id: "includeChildTasks", name: t("Include Child Tasks") },
|
||||
{ id: "includeSiblingTasks", name: t("Include Sibling Tasks") },
|
||||
];
|
||||
|
||||
for (const option of relatedOptions) {
|
||||
new Setting(contentEl).setName(option.name).addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.preset.options[option.id])
|
||||
.onChange((value) => {
|
||||
this.preset.options[option.id] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Advanced filter section
|
||||
new Setting(contentEl)
|
||||
.setName(t("Advanced Filter"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use boolean operations: AND, OR, NOT. Example: 'text content AND #tag1'"
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName(t("Filter query"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use boolean operations: AND, OR, NOT. Example: 'text content AND #tag1'"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setValue(this.preset.options.advancedFilterQuery).onChange(
|
||||
(value) => {
|
||||
this.preset.options.advancedFilterQuery = value;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName(t("Filter Mode"))
|
||||
.setDesc(
|
||||
t("Choose whether to show or hide tasks that match the filters")
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("INCLUDE", t("Show matching tasks"))
|
||||
.addOption("EXCLUDE", t("Hide matching tasks"))
|
||||
.setValue(this.preset.options.filterMode || "INCLUDE")
|
||||
.onChange((value: "INCLUDE" | "EXCLUDE") => {
|
||||
this.preset.options.filterMode = value;
|
||||
});
|
||||
});
|
||||
|
||||
// Save and cancel buttons
|
||||
new Setting(contentEl)
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Save"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onSave();
|
||||
this.close();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Cancel")).onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTaskFilterSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl).setName(t("Task Filter")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable Task Filter"))
|
||||
.setDesc(t("Toggle this to enable the task filter panel"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.taskFilter.enableTaskFilter
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.taskFilter.enableTaskFilter =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Preset filters section
|
||||
new Setting(containerEl)
|
||||
.setName(t("Preset Filters"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Create and manage preset filters for quick access to commonly used task filters."
|
||||
)
|
||||
);
|
||||
|
||||
// Add a container for the preset filters
|
||||
const presetFiltersContainer = containerEl.createDiv({
|
||||
cls: "preset-filters-container",
|
||||
});
|
||||
|
||||
// Function to refresh the preset filters list
|
||||
const refreshPresetFiltersList = () => {
|
||||
// Clear the container
|
||||
presetFiltersContainer.empty();
|
||||
|
||||
// Get current preset filters
|
||||
const presetFilters =
|
||||
settingTab.plugin.settings.taskFilter.presetTaskFilters;
|
||||
|
||||
if (presetFilters.length === 0) {
|
||||
presetFiltersContainer.createEl("div", {
|
||||
cls: "no-presets-message",
|
||||
text: t(
|
||||
"No preset filters created yet. Click 'Add New Preset' to create one."
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add each preset filter in the list
|
||||
presetFilters.forEach((preset, index) => {
|
||||
const presetRow = presetFiltersContainer.createDiv({
|
||||
cls: "preset-filter-row",
|
||||
});
|
||||
|
||||
// Create the setting
|
||||
const presetSetting = new Setting(presetRow)
|
||||
.setName(`${t("Preset")} #${index + 1}`)
|
||||
.addText((text) => {
|
||||
text.setValue(preset.name)
|
||||
.setPlaceholder(t("Preset name"))
|
||||
.onChange((value) => {
|
||||
preset.name = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Add buttons for editing, removing
|
||||
presetSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("pencil")
|
||||
.setTooltip(t("Edit Filter"))
|
||||
.onClick(() => {
|
||||
// Show modal to edit filter options
|
||||
new PresetFilterModal(settingTab.app, preset, () => {
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshPresetFiltersList();
|
||||
}).open();
|
||||
});
|
||||
});
|
||||
|
||||
presetSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove"))
|
||||
.onClick(() => {
|
||||
// Remove the preset
|
||||
presetFilters.splice(index, 1);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshPresetFiltersList();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add button to add new preset
|
||||
const addButtonContainer = presetFiltersContainer.createDiv();
|
||||
new Setting(addButtonContainer)
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add New Preset"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
// Add a new preset with default options
|
||||
const newPreset = {
|
||||
id: generateUniqueId(),
|
||||
name: t("New Filter"),
|
||||
options: {
|
||||
includeCompleted: true,
|
||||
includeInProgress: true,
|
||||
includeAbandoned: true,
|
||||
includeNotStarted: true,
|
||||
includePlanned: true,
|
||||
includeParentTasks: true,
|
||||
includeChildTasks: true,
|
||||
includeSiblingTasks: false,
|
||||
advancedFilterQuery: "",
|
||||
filterMode: "INCLUDE" as "INCLUDE" | "EXCLUDE",
|
||||
},
|
||||
};
|
||||
|
||||
settingTab.plugin.settings.taskFilter.presetTaskFilters.push(
|
||||
newPreset
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
// Open the edit modal for the new preset
|
||||
new PresetFilterModal(settingTab.app, newPreset, () => {
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshPresetFiltersList();
|
||||
}).open();
|
||||
|
||||
refreshPresetFiltersList();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Reset to Default Presets"))
|
||||
.onClick(() => {
|
||||
// Show confirmation modal
|
||||
const modal = new Modal(settingTab.app);
|
||||
modal.titleEl.setText(t("Reset to Default Presets"));
|
||||
|
||||
const content = modal.contentEl.createDiv();
|
||||
content.setText(
|
||||
t(
|
||||
"This will replace all your current presets with the default set. Are you sure?"
|
||||
)
|
||||
);
|
||||
|
||||
const buttonContainer = modal.contentEl.createDiv({
|
||||
cls: "tg-modal-button-container modal-button-container",
|
||||
});
|
||||
|
||||
const cancelButton = buttonContainer.createEl("button");
|
||||
cancelButton.setText(t("Cancel"));
|
||||
cancelButton.addEventListener("click", () => {
|
||||
modal.close();
|
||||
});
|
||||
|
||||
const confirmButton =
|
||||
buttonContainer.createEl("button");
|
||||
confirmButton.setText(t("Reset"));
|
||||
confirmButton.addClass("mod-warning");
|
||||
confirmButton.addEventListener("click", () => {
|
||||
createDefaultPresetFilters(settingTab);
|
||||
refreshPresetFiltersList();
|
||||
modal.close();
|
||||
});
|
||||
|
||||
modal.open();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Initial render of the preset filters list
|
||||
refreshPresetFiltersList();
|
||||
}
|
||||
|
||||
function createDefaultPresetFilters(settingTab: TaskProgressBarSettingTab) {
|
||||
// Clear existing presets if any
|
||||
settingTab.plugin.settings.taskFilter.presetTaskFilters = [];
|
||||
|
||||
// Add default presets
|
||||
const defaultPresets = [
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
name: t("Incomplete tasks"),
|
||||
options: {
|
||||
includeCompleted: false,
|
||||
includeInProgress: true,
|
||||
includeAbandoned: false,
|
||||
includeNotStarted: true,
|
||||
includePlanned: true,
|
||||
includeParentTasks: true,
|
||||
includeChildTasks: true,
|
||||
includeSiblingTasks: false,
|
||||
advancedFilterQuery: "",
|
||||
filterMode: "INCLUDE" as "INCLUDE" | "EXCLUDE",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
name: t("In progress tasks"),
|
||||
options: {
|
||||
includeCompleted: false,
|
||||
includeInProgress: true,
|
||||
includeAbandoned: false,
|
||||
includeNotStarted: false,
|
||||
includePlanned: false,
|
||||
includeParentTasks: true,
|
||||
includeChildTasks: true,
|
||||
includeSiblingTasks: false,
|
||||
advancedFilterQuery: "",
|
||||
filterMode: "INCLUDE" as "INCLUDE" | "EXCLUDE",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
name: t("Completed tasks"),
|
||||
options: {
|
||||
includeCompleted: true,
|
||||
includeInProgress: false,
|
||||
includeAbandoned: false,
|
||||
includeNotStarted: false,
|
||||
includePlanned: false,
|
||||
includeParentTasks: false,
|
||||
includeChildTasks: true,
|
||||
includeSiblingTasks: false,
|
||||
advancedFilterQuery: "",
|
||||
filterMode: "INCLUDE" as "INCLUDE" | "EXCLUDE",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
name: t("All tasks"),
|
||||
options: {
|
||||
includeCompleted: true,
|
||||
includeInProgress: true,
|
||||
includeAbandoned: true,
|
||||
includeNotStarted: true,
|
||||
includePlanned: true,
|
||||
includeParentTasks: true,
|
||||
includeChildTasks: true,
|
||||
includeSiblingTasks: true,
|
||||
advancedFilterQuery: "",
|
||||
filterMode: "INCLUDE" as "INCLUDE" | "EXCLUDE",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add default presets to settings
|
||||
settingTab.plugin.settings.taskFilter.presetTaskFilters = defaultPresets;
|
||||
settingTab.applySettingsUpdate();
|
||||
}
|
||||
732
src/components/settings/TaskHandlerSettingsTab.ts
Normal file
732
src/components/settings/TaskHandlerSettingsTab.ts
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
import { Setting } from "obsidian";
|
||||
import {
|
||||
SortCriterion,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../common/setting-definition";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
export function renderTaskHandlerSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task Gutter"))
|
||||
.setDesc(t("Configure the task gutter."))
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable task gutter"))
|
||||
.setDesc(t("Toggle this to enable the task gutter."))
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.taskGutter.enableTaskGutter
|
||||
);
|
||||
toggle.onChange(async (value) => {
|
||||
settingTab.plugin.settings.taskGutter.enableTaskGutter = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Add Completed Task Mover settings
|
||||
new Setting(containerEl).setName(t("Completed Task Mover")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable completed task mover"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable commands for moving completed tasks to another file."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.enableCompletedTaskMover
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.enableCompletedTaskMover =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.completedTaskMover.enableCompletedTaskMover
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task marker type"))
|
||||
.setDesc(t("Choose what type of marker to add to moved tasks"))
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("version", "Version marker")
|
||||
.addOption("date", "Date marker")
|
||||
.addOption("custom", "Custom marker")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.taskMarkerType
|
||||
)
|
||||
.onChange(async (value: "version" | "date" | "custom") => {
|
||||
settingTab.plugin.settings.completedTaskMover.taskMarkerType =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Show specific settings based on marker type
|
||||
const markerType =
|
||||
settingTab.plugin.settings.completedTaskMover.taskMarkerType;
|
||||
|
||||
if (markerType === "version") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Version marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Text to append to tasks when moved (e.g., 'version 1.0')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("version 1.0")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.versionMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.versionMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
} else if (markerType === "date") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Date marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Text to append to tasks when moved (e.g., 'archived on 2023-12-31')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("archived on {{date}}")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.dateMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.dateMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
} else if (markerType === "custom") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Custom marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use {{DATE:format}} for date formatting (e.g., {{DATE:YYYY-MM-DD}}"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("moved {{DATE:YYYY-MM-DD HH:mm}}")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.customMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.customMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Treat abandoned tasks as completed"))
|
||||
.setDesc(
|
||||
t("If enabled, abandoned tasks will be treated as completed.")
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.treatAbandonedAsCompleted
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.treatAbandonedAsCompleted =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Complete all moved tasks"))
|
||||
.setDesc(
|
||||
t("If enabled, all moved tasks will be marked as completed.")
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.completeAllMovedTasks
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.completeAllMovedTasks =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("With current file link"))
|
||||
.setDesc(
|
||||
t(
|
||||
"A link to the current file will be added to the parent task of the moved tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.withCurrentFileLink
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.withCurrentFileLink =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-move settings for completed tasks
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable auto-move for completed tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Automatically move completed tasks to a default file without manual selection."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover.enableAutoMove
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.enableAutoMove =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh to show/hide auto-move settings
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.completedTaskMover.enableAutoMove) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default target file"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Default file to move completed tasks to (e.g., 'Archive.md')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Archive.md")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.defaultTargetFile
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.defaultTargetFile =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default insertion mode"))
|
||||
.setDesc(
|
||||
t("Where to insert completed tasks in the target file")
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("beginning", t("Beginning of file"))
|
||||
.addOption("end", t("End of file"))
|
||||
.addOption("after-heading", t("After heading"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.defaultInsertionMode
|
||||
)
|
||||
.onChange(
|
||||
async (
|
||||
value: "beginning" | "end" | "after-heading"
|
||||
) => {
|
||||
settingTab.plugin.settings.completedTaskMover.defaultInsertionMode =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh to show/hide heading setting
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.defaultInsertionMode === "after-heading"
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default heading name"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Heading name to insert tasks after (will be created if it doesn't exist)"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Completed Tasks")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.defaultHeadingName
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.defaultHeadingName =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Incomplete Task Mover settings
|
||||
new Setting(containerEl).setName(t("Incomplete Task Mover")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable incomplete task mover"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable commands for moving incomplete tasks to another file."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.enableIncompletedTaskMover
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.enableIncompletedTaskMover =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.completedTaskMover.enableIncompletedTaskMover
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Incomplete task marker type"))
|
||||
.setDesc(
|
||||
t("Choose what type of marker to add to moved incomplete tasks")
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("version", "Version marker")
|
||||
.addOption("date", "Date marker")
|
||||
.addOption("custom", "Custom marker")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedTaskMarkerType
|
||||
)
|
||||
.onChange(async (value: "version" | "date" | "custom") => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedTaskMarkerType =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Show specific settings based on marker type
|
||||
const incompletedMarkerType =
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedTaskMarkerType;
|
||||
|
||||
if (incompletedMarkerType === "version") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Incomplete version marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Text to append to incomplete tasks when moved (e.g., 'version 1.0')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("version 1.0")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedVersionMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedVersionMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
} else if (incompletedMarkerType === "date") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Incomplete date marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Text to append to incomplete tasks when moved (e.g., 'moved on 2023-12-31')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("moved on {{date}}")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedDateMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedDateMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
} else if (incompletedMarkerType === "custom") {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Incomplete custom marker text"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use {{DATE:format}} for date formatting (e.g., {{DATE:YYYY-MM-DD}}"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("moved {{DATE:YYYY-MM-DD HH:mm}}")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedCustomMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedCustomMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("With current file link for incomplete tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"A link to the current file will be added to the parent task of the moved incomplete tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.withCurrentFileLinkForIncompleted
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.withCurrentFileLinkForIncompleted =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-move settings for incomplete tasks
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable auto-move for incomplete tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Automatically move incomplete tasks to a default file without manual selection."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.enableIncompletedAutoMove
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.enableIncompletedAutoMove =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh to show/hide auto-move settings
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.enableIncompletedAutoMove
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default target file for incomplete tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Default file to move incomplete tasks to (e.g., 'Backlog.md')"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Backlog.md")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedDefaultTargetFile
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedDefaultTargetFile =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default insertion mode for incomplete tasks"))
|
||||
.setDesc(
|
||||
t("Where to insert incomplete tasks in the target file")
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("beginning", t("Beginning of file"))
|
||||
.addOption("end", t("End of file"))
|
||||
.addOption("after-heading", t("After heading"))
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedDefaultInsertionMode
|
||||
)
|
||||
.onChange(
|
||||
async (
|
||||
value: "beginning" | "end" | "after-heading"
|
||||
) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedDefaultInsertionMode =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh to show/hide heading setting
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedDefaultInsertionMode === "after-heading"
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default heading name for incomplete tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Heading name to insert incomplete tasks after (will be created if it doesn't exist)"
|
||||
)
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Incomplete Tasks")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.completedTaskMover
|
||||
.incompletedDefaultHeadingName
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.completedTaskMover.incompletedDefaultHeadingName =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Sorting Settings ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task Sorting"))
|
||||
.setDesc(t("Configure how tasks are sorted in the document."))
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable Task Sorting"))
|
||||
.setDesc(t("Toggle this to enable commands for sorting tasks."))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.sortTasks)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.sortTasks = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
// Refresh the settings display to show/hide criteria section
|
||||
settingTab.display(); // Or just this section if optimized
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.sortTasks) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Sort Criteria"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Define the order in which tasks should be sorted. Criteria are applied sequentially."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
const criteriaContainer = containerEl.createDiv({
|
||||
cls: "sort-criteria-container",
|
||||
});
|
||||
|
||||
const refreshCriteriaList = () => {
|
||||
criteriaContainer.empty();
|
||||
const criteria = settingTab.plugin.settings.sortCriteria || [];
|
||||
|
||||
if (criteria.length === 0) {
|
||||
criteriaContainer.createEl("p", {
|
||||
text: t("No sort criteria defined. Add criteria below."),
|
||||
cls: "setting-item-description",
|
||||
});
|
||||
}
|
||||
|
||||
criteria.forEach((criterion, index) => {
|
||||
const criterionSetting = new Setting(criteriaContainer)
|
||||
.setClass("sort-criterion-row")
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("status", t("Status"))
|
||||
.addOption("priority", t("Priority"))
|
||||
.addOption("dueDate", t("Due Date"))
|
||||
.addOption("startDate", t("Start Date"))
|
||||
.addOption("scheduledDate", t("Scheduled Date"))
|
||||
.addOption("content", t("Content"))
|
||||
.addOption("lineNumber", t("Line Number"))
|
||||
.setValue(criterion.field)
|
||||
.onChange((value: SortCriterion["field"]) => {
|
||||
settingTab.plugin.settings.sortCriteria[
|
||||
index
|
||||
].field = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
})
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("asc", t("Ascending")) // Ascending might mean different things (e.g., High -> Low for priority)
|
||||
.addOption("desc", t("Descending")) // Descending might mean different things (e.g., Low -> High for priority)
|
||||
.setValue(criterion.order)
|
||||
.onChange((value: SortCriterion["order"]) => {
|
||||
settingTab.plugin.settings.sortCriteria[
|
||||
index
|
||||
].order = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
// Add tooltips explaining what asc/desc means for each field type if possible
|
||||
if (criterion.field === "priority") {
|
||||
dropdown.selectEl.title = t(
|
||||
"Ascending: High -> Low -> None. Descending: None -> Low -> High"
|
||||
);
|
||||
} else if (
|
||||
["dueDate", "startDate", "scheduledDate"].includes(
|
||||
criterion.field
|
||||
)
|
||||
) {
|
||||
dropdown.selectEl.title = t(
|
||||
"Ascending: Earlier -> Later -> None. Descending: None -> Later -> Earlier"
|
||||
);
|
||||
} else if (criterion.field === "status") {
|
||||
dropdown.selectEl.title = t(
|
||||
"Ascending respects status order (Overdue first). Descending reverses it."
|
||||
);
|
||||
} else {
|
||||
dropdown.selectEl.title = t(
|
||||
"Ascending: A-Z. Descending: Z-A"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Controls for reordering and deleting
|
||||
criterionSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("arrow-up")
|
||||
.setTooltip(t("Move Up"))
|
||||
.setDisabled(index === 0)
|
||||
.onClick(() => {
|
||||
if (index > 0) {
|
||||
const item =
|
||||
settingTab.plugin.settings.sortCriteria.splice(
|
||||
index,
|
||||
1
|
||||
)[0];
|
||||
settingTab.plugin.settings.sortCriteria.splice(
|
||||
index - 1,
|
||||
0,
|
||||
item
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshCriteriaList();
|
||||
}
|
||||
});
|
||||
});
|
||||
criterionSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("arrow-down")
|
||||
.setTooltip(t("Move Down"))
|
||||
.setDisabled(index === criteria.length - 1)
|
||||
.onClick(() => {
|
||||
if (index < criteria.length - 1) {
|
||||
const item =
|
||||
settingTab.plugin.settings.sortCriteria.splice(
|
||||
index,
|
||||
1
|
||||
)[0];
|
||||
settingTab.plugin.settings.sortCriteria.splice(
|
||||
index + 1,
|
||||
0,
|
||||
item
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshCriteriaList();
|
||||
}
|
||||
});
|
||||
});
|
||||
criterionSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove Criterion"))
|
||||
.onClick(() => {
|
||||
settingTab.plugin.settings.sortCriteria.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshCriteriaList();
|
||||
});
|
||||
// Add class to the container element of the extra button
|
||||
button.extraSettingsEl.addClass("mod-warning");
|
||||
});
|
||||
});
|
||||
|
||||
// Button to add a new criterion
|
||||
new Setting(criteriaContainer)
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add Sort Criterion"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
const newCriterion: SortCriterion = {
|
||||
field: "status",
|
||||
order: "asc",
|
||||
};
|
||||
if (!settingTab.plugin.settings.sortCriteria) {
|
||||
settingTab.plugin.settings.sortCriteria = [];
|
||||
}
|
||||
settingTab.plugin.settings.sortCriteria.push(
|
||||
newCriterion
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshCriteriaList();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
// Button to reset to defaults
|
||||
button.setButtonText(t("Reset to Defaults")).onClick(() => {
|
||||
// Optional: Add confirmation dialog here
|
||||
settingTab.plugin.settings.sortCriteria = [
|
||||
...DEFAULT_SETTINGS.sortCriteria,
|
||||
]; // Use spread to copy
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshCriteriaList();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
refreshCriteriaList(); // Initial render
|
||||
}
|
||||
}
|
||||
1020
src/components/settings/TaskStatusSettingsTab.ts
Normal file
1020
src/components/settings/TaskStatusSettingsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
132
src/components/settings/TimelineSidebarSettingsTab.ts
Normal file
132
src/components/settings/TimelineSidebarSettingsTab.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Setting, Notice } from "obsidian";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
export function renderTimelineSidebarSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl).setName(t("Timeline Sidebar")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable Timeline Sidebar"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Toggle this to enable the timeline sidebar view for quick access to your daily events and tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.timelineSidebar
|
||||
.enableTimelineSidebar
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.timelineSidebar.enableTimelineSidebar =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
if (value) {
|
||||
settingTab.plugin.activateTimelineSidebarView();
|
||||
}
|
||||
}, 200);
|
||||
})
|
||||
);
|
||||
|
||||
if (!settingTab.plugin.settings.timelineSidebar.enableTimelineSidebar)
|
||||
return;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Auto-open on startup"))
|
||||
.setDesc(
|
||||
t("Automatically open the timeline sidebar when Obsidian starts.")
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.timelineSidebar.autoOpenOnStartup
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.timelineSidebar.autoOpenOnStartup =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Show completed tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Include completed tasks in the timeline view. When disabled, only incomplete tasks will be shown."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.timelineSidebar
|
||||
.showCompletedTasks
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.timelineSidebar.showCompletedTasks =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Focus mode by default"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enable focus mode by default, which highlights today's events and dims past/future events."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.timelineSidebar
|
||||
.focusModeByDefault
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.timelineSidebar.focusModeByDefault =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Maximum events to show"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Maximum number of events to display in the timeline. Higher numbers may affect performance."
|
||||
)
|
||||
)
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(50, 500, 25)
|
||||
.setValue(
|
||||
settingTab.plugin.settings.timelineSidebar.maxEventsToShow
|
||||
)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.timelineSidebar.maxEventsToShow =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
// Add a button to open the timeline sidebar
|
||||
new Setting(containerEl)
|
||||
.setName(t("Open Timeline Sidebar"))
|
||||
.setDesc(t("Click to open the timeline sidebar view."))
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText(t("Open Timeline"))
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await settingTab.plugin.activateTimelineSidebarView();
|
||||
new Notice(t("Timeline sidebar opened"));
|
||||
})
|
||||
);
|
||||
}
|
||||
835
src/components/settings/ViewSettingsTab.ts
Normal file
835
src/components/settings/ViewSettingsTab.ts
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
import { Setting, Notice, setIcon } from "obsidian";
|
||||
import { ViewConfig, ViewFilterRule } from "../../common/setting-definition";
|
||||
import { t } from "../../translations/helper";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { SingleFolderSuggest } from "../AutoComplete";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ViewConfigModal } from "../ViewConfigModal";
|
||||
|
||||
export function renderViewSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("View & Index Configuration"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Configure the Task Genius sidebar views, visibility, order, and create custom views."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable task genius view"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enable task genius view will also enable the task genius indexer, which will provide the task genius view results from whole vault."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settingTab.plugin.settings.enableView);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.enableView = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh settings display
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Prefer metadata format of task"))
|
||||
.setDesc(
|
||||
t(
|
||||
"You can choose dataview format or tasks format, that will influence both index and save format."
|
||||
)
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("dataview", "Dataview")
|
||||
.addOption("tasks", "Tasks")
|
||||
.setValue(settingTab.plugin.settings.preferMetadataFormat)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.preferMetadataFormat = value as
|
||||
| "dataview"
|
||||
| "tasks";
|
||||
settingTab.applySettingsUpdate();
|
||||
// Re-render the settings to update prefix configuration UI
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// Task Parser Configuration Section
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task Parser Configuration"))
|
||||
.setDesc(t("Configure how task metadata is parsed and recognized."))
|
||||
.setHeading();
|
||||
|
||||
// Get current metadata format to show appropriate settings
|
||||
const isDataviewFormat =
|
||||
settingTab.plugin.settings.preferMetadataFormat === "dataview";
|
||||
|
||||
// Project tag prefix
|
||||
new Setting(containerEl)
|
||||
.setName(t("Project tag prefix"))
|
||||
.setDesc(
|
||||
isDataviewFormat
|
||||
? t(
|
||||
"Customize the prefix used for project tags in dataview format (e.g., 'project' for [project:: myproject]). Changes require reindexing."
|
||||
)
|
||||
: t(
|
||||
"Customize the prefix used for project tags (e.g., 'project' for #project/myproject). Changes require reindexing."
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("project")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.projectTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.projectTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
] = value || "project";
|
||||
settingTab.applySettingsUpdate();
|
||||
// Update format examples
|
||||
const updateFn = (containerEl as any).updateFormatExamples;
|
||||
if (updateFn) updateFn();
|
||||
});
|
||||
});
|
||||
|
||||
// Context tag prefix with special handling
|
||||
new Setting(containerEl)
|
||||
.setName(t("Context tag prefix"))
|
||||
.setDesc(
|
||||
isDataviewFormat
|
||||
? t(
|
||||
"Customize the prefix used for context tags in dataview format (e.g., 'context' for [context:: home]). Changes require reindexing."
|
||||
)
|
||||
: t(
|
||||
"Customize the prefix used for context tags (e.g., '@home' for @home). Changes require reindexing."
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("context")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.contextTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.contextTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
] = value || (isDataviewFormat ? "context" : "@");
|
||||
settingTab.applySettingsUpdate();
|
||||
// Update format examples
|
||||
const updateFn = (containerEl as any).updateFormatExamples;
|
||||
if (updateFn) updateFn();
|
||||
});
|
||||
});
|
||||
|
||||
// Area tag prefix
|
||||
new Setting(containerEl)
|
||||
.setName(t("Area tag prefix"))
|
||||
.setDesc(
|
||||
isDataviewFormat
|
||||
? t(
|
||||
"Customize the prefix used for area tags in dataview format (e.g., 'area' for [area:: work]). Changes require reindexing."
|
||||
)
|
||||
: t(
|
||||
"Customize the prefix used for area tags (e.g., 'area' for #area/work). Changes require reindexing."
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("area")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.areaTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.areaTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
] = value || "area";
|
||||
settingTab.applySettingsUpdate();
|
||||
// Update format examples
|
||||
const updateFn = (containerEl as any).updateFormatExamples;
|
||||
if (updateFn) updateFn();
|
||||
});
|
||||
});
|
||||
|
||||
// Add format examples section
|
||||
const exampleContainer = containerEl.createDiv({
|
||||
cls: "task-genius-format-examples",
|
||||
});
|
||||
|
||||
// Function to update format examples
|
||||
const updateFormatExamples = () => {
|
||||
exampleContainer.empty();
|
||||
exampleContainer.createEl("strong", {
|
||||
text: t("Format Examples:"),
|
||||
});
|
||||
|
||||
const currentIsDataviewFormat =
|
||||
settingTab.plugin.settings.preferMetadataFormat === "dataview";
|
||||
|
||||
if (currentIsDataviewFormat) {
|
||||
exampleContainer.createEl("br");
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Project")}: [${
|
||||
settingTab.plugin.settings.projectTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
}:: myproject]`,
|
||||
});
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Context")}: [${
|
||||
settingTab.plugin.settings.contextTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
}:: home]`,
|
||||
});
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Area")}: [${
|
||||
settingTab.plugin.settings.areaTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
}:: work]`,
|
||||
});
|
||||
} else {
|
||||
exampleContainer.createEl("br");
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Project")}: #${
|
||||
settingTab.plugin.settings.projectTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
}/myproject`,
|
||||
});
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Context")}: @home (${t("always uses @ prefix")})`,
|
||||
});
|
||||
exampleContainer.createEl("span", {
|
||||
text: `• ${t("Area")}: #${
|
||||
settingTab.plugin.settings.areaTagPrefix[
|
||||
settingTab.plugin.settings.preferMetadataFormat
|
||||
]
|
||||
}/work`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial display of format examples
|
||||
updateFormatExamples();
|
||||
|
||||
// Store the update function for later use
|
||||
(containerEl as any).updateFormatExamples = updateFormatExamples;
|
||||
|
||||
// File Parsing Configuration Section
|
||||
new Setting(containerEl)
|
||||
.setName(t("File Parsing Configuration"))
|
||||
.setDesc(
|
||||
t("Configure how to extract tasks from file metadata and tags.")
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable file metadata parsing"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Parse tasks from file frontmatter metadata fields. When enabled, files with specific metadata fields will be treated as tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.enableFileMetadataParsing
|
||||
);
|
||||
toggle.onChange(async (value) => {
|
||||
const previousValue =
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.enableFileMetadataParsing;
|
||||
settingTab.plugin.settings.fileParsingConfig.enableFileMetadataParsing =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
// If file metadata parsing was just enabled, trigger a full reindex
|
||||
if (!previousValue && value && settingTab.plugin.taskManager) {
|
||||
try {
|
||||
new Notice(
|
||||
t(
|
||||
"File metadata parsing enabled. Rebuilding task index..."
|
||||
)
|
||||
);
|
||||
await settingTab.plugin.taskManager.forceReindex();
|
||||
new Notice(t("Task index rebuilt successfully"));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to reindex after enabling file metadata parsing:",
|
||||
error
|
||||
);
|
||||
new Notice(t("Failed to rebuild task index"));
|
||||
}
|
||||
}
|
||||
|
||||
settingTab.display(); // Refresh to show/hide related settings
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.fileParsingConfig.enableFileMetadataParsing
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Metadata fields to parse as tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Comma-separated list of metadata fields that should be treated as tasks (e.g., dueDate, todo, complete, task)"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("dueDate, todo, complete, task")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig.metadataFieldsToParseAsTasks.join(
|
||||
", "
|
||||
)
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.metadataFieldsToParseAsTasks =
|
||||
value
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field.length > 0);
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Task content from metadata"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Which metadata field to use as task content. If not found, will use filename."
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("title")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.taskContentFromMetadata
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.taskContentFromMetadata =
|
||||
value || "title";
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Default task status"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Default status for tasks created from metadata (space for incomplete, x for complete)"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(" ")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.defaultTaskStatus
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.defaultTaskStatus =
|
||||
value || " ";
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable tag-based task parsing"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Parse tasks from file tags. When enabled, files with specific tags will be treated as tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.enableTagBasedTaskParsing
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.enableTagBasedTaskParsing =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
settingTab.display(); // Refresh to show/hide related settings
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
settingTab.plugin.settings.fileParsingConfig.enableTagBasedTaskParsing
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Tags to parse as tasks"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Comma-separated list of tags that should be treated as tasks (e.g., #todo, #task, #action, #due)"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("#todo, #task, #action, #due")
|
||||
.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig.tagsToParseAsTasks.join(
|
||||
", "
|
||||
)
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.tagsToParseAsTasks =
|
||||
value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable worker processing"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use background worker for file parsing to improve performance. Recommended for large vaults."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(
|
||||
settingTab.plugin.settings.fileParsingConfig
|
||||
.enableWorkerProcessing
|
||||
);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.fileParsingConfig.enableWorkerProcessing =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Use daily note path as date"))
|
||||
.setDesc(
|
||||
t(
|
||||
"If enabled, the daily note path will be used as the date for tasks."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settingTab.plugin.settings.useDailyNotePathAsDate);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.useDailyNotePathAsDate = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.useDailyNotePathAsDate) {
|
||||
const descFragment = document.createDocumentFragment();
|
||||
descFragment.createEl("div", {
|
||||
text: t(
|
||||
"Task Genius will use moment.js and also this format to parse the daily note path."
|
||||
),
|
||||
});
|
||||
descFragment.createEl("div", {
|
||||
text: t(
|
||||
"You need to set `yyyy` instead of `YYYY` in the format string. And `dd` instead of `DD`."
|
||||
),
|
||||
});
|
||||
new Setting(containerEl)
|
||||
.setName(t("Daily note format"))
|
||||
.setDesc(descFragment)
|
||||
.addText((text) => {
|
||||
text.setValue(settingTab.plugin.settings.dailyNoteFormat);
|
||||
text.onChange((value) => {
|
||||
settingTab.plugin.settings.dailyNoteFormat = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Daily note path"))
|
||||
.setDesc(t("Select the folder that contains the daily note."))
|
||||
.addText((text) => {
|
||||
new SingleFolderSuggest(
|
||||
settingTab.app,
|
||||
text.inputEl,
|
||||
settingTab.plugin
|
||||
);
|
||||
text.setValue(settingTab.plugin.settings.dailyNotePath);
|
||||
text.onChange((value) => {
|
||||
settingTab.plugin.settings.dailyNotePath = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Use as date type"))
|
||||
.setDesc(
|
||||
t(
|
||||
"You can choose due, start, or scheduled as the date type for tasks."
|
||||
)
|
||||
)
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption("due", t("Due"))
|
||||
.addOption("start", t("Start"))
|
||||
.addOption("scheduled", t("Scheduled"))
|
||||
.setValue(settingTab.plugin.settings.useAsDateType)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.useAsDateType = value as
|
||||
| "due"
|
||||
| "start"
|
||||
| "scheduled";
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Use relative time for date"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Use relative time for date in task list item, e.g. 'yesterday', 'today', 'tomorrow', 'in 2 days', '3 months ago', etc."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settingTab.plugin.settings.useRelativeTimeForDate);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.useRelativeTimeForDate = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable inline editor"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enable inline editing of task content and metadata directly in task views. When disabled, tasks can only be edited in the source file."
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(settingTab.plugin.settings.enableInlineEditor);
|
||||
toggle.onChange((value) => {
|
||||
settingTab.plugin.settings.enableInlineEditor = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Ignore all tasks behind heading"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enter the heading to ignore, e.g. '## Project', '## Inbox', separated by comma"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setValue(settingTab.plugin.settings.ignoreHeading);
|
||||
text.onChange((value) => {
|
||||
settingTab.plugin.settings.ignoreHeading = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Focus all tasks behind heading"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Enter the heading to focus, e.g. '## Project', '## Inbox', separated by comma"
|
||||
)
|
||||
)
|
||||
.addText((text) => {
|
||||
text.setValue(settingTab.plugin.settings.focusHeading);
|
||||
text.onChange((value) => {
|
||||
settingTab.plugin.settings.focusHeading = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
if (!settingTab.plugin.settings.enableView) return;
|
||||
|
||||
// --- New View Management Section ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Manage Views"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Configure sidebar views, order, visibility, and hide/show completed tasks per view."
|
||||
)
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
const viewListContainer = containerEl.createDiv({
|
||||
cls: "view-management-list",
|
||||
});
|
||||
|
||||
// Function to render the list of views
|
||||
const renderViewList = () => {
|
||||
viewListContainer.empty();
|
||||
|
||||
settingTab.plugin.settings.viewConfiguration.forEach((view, index) => {
|
||||
const viewSetting = new Setting(viewListContainer)
|
||||
.setName(view.name)
|
||||
.setDesc(`[${view.type}]`)
|
||||
.addToggle((toggle) => {
|
||||
/* Visibility Toggle */
|
||||
toggle
|
||||
.setTooltip(t("Show in sidebar"))
|
||||
.setValue(view.visible)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.viewConfiguration[
|
||||
index
|
||||
].visible = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Edit button - Now available for ALL views to edit rules/name/icon
|
||||
viewSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("pencil")
|
||||
.setTooltip(t("Edit View"))
|
||||
.onClick(() => {
|
||||
if (view.id === "habit") {
|
||||
settingTab.openTab("habit");
|
||||
return;
|
||||
}
|
||||
// Get current rules (might be undefined for defaults initially)
|
||||
const currentRules = view.filterRules || {};
|
||||
new ViewConfigModal(
|
||||
settingTab.app,
|
||||
settingTab.plugin,
|
||||
view,
|
||||
currentRules,
|
||||
(
|
||||
updatedView: ViewConfig,
|
||||
updatedRules: ViewFilterRule
|
||||
) => {
|
||||
const currentIndex =
|
||||
settingTab.plugin.settings.viewConfiguration.findIndex(
|
||||
(v) => v.id === updatedView.id
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
// Update the view config in the array
|
||||
settingTab.plugin.settings.viewConfiguration[
|
||||
currentIndex
|
||||
] = {
|
||||
...updatedView,
|
||||
filterRules: updatedRules,
|
||||
}; // Ensure rules are saved back to viewConfig
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList(); // Re-render the settings list
|
||||
}
|
||||
}
|
||||
).open();
|
||||
});
|
||||
button.extraSettingsEl.addClass("view-edit-button"); // Add class for potential styling
|
||||
});
|
||||
|
||||
// Copy button - Available for ALL views to create a copy
|
||||
viewSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("copy")
|
||||
.setTooltip(t("Copy View"))
|
||||
.onClick(() => {
|
||||
// Create a copy of the current view
|
||||
new ViewConfigModal(
|
||||
settingTab.app,
|
||||
settingTab.plugin,
|
||||
null, // null for create mode
|
||||
null, // null for create mode
|
||||
(
|
||||
createdView: ViewConfig,
|
||||
createdRules: ViewFilterRule
|
||||
) => {
|
||||
if (
|
||||
!settingTab.plugin.settings.viewConfiguration.some(
|
||||
(v) => v.id === createdView.id
|
||||
)
|
||||
) {
|
||||
// Save with filter rules embedded
|
||||
settingTab.plugin.settings.viewConfiguration.push(
|
||||
{
|
||||
...createdView,
|
||||
filterRules: createdRules,
|
||||
}
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList();
|
||||
new Notice(
|
||||
t("View copied successfully: ") +
|
||||
createdView.name
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
t("Error: View ID already exists.")
|
||||
);
|
||||
}
|
||||
},
|
||||
view // 传入当前视图作为拷贝源
|
||||
).open();
|
||||
});
|
||||
button.extraSettingsEl.addClass("view-copy-button");
|
||||
});
|
||||
|
||||
// Reordering buttons
|
||||
viewSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("arrow-up")
|
||||
.setTooltip(t("Move Up"))
|
||||
.setDisabled(index === 0)
|
||||
.onClick(() => {
|
||||
if (index > 0) {
|
||||
const item =
|
||||
settingTab.plugin.settings.viewConfiguration.splice(
|
||||
index,
|
||||
1
|
||||
)[0];
|
||||
settingTab.plugin.settings.viewConfiguration.splice(
|
||||
index - 1,
|
||||
0,
|
||||
item
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList(); // Re-render the list
|
||||
}
|
||||
});
|
||||
button.extraSettingsEl.addClass("view-order-button");
|
||||
});
|
||||
viewSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("arrow-down")
|
||||
.setTooltip(t("Move Down"))
|
||||
.setDisabled(
|
||||
index ===
|
||||
settingTab.plugin.settings.viewConfiguration
|
||||
.length -
|
||||
1
|
||||
)
|
||||
.onClick(() => {
|
||||
if (
|
||||
index <
|
||||
settingTab.plugin.settings.viewConfiguration
|
||||
.length -
|
||||
1
|
||||
) {
|
||||
const item =
|
||||
settingTab.plugin.settings.viewConfiguration.splice(
|
||||
index,
|
||||
1
|
||||
)[0];
|
||||
settingTab.plugin.settings.viewConfiguration.splice(
|
||||
index + 1,
|
||||
0,
|
||||
item
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList(); // Re-render the list
|
||||
}
|
||||
});
|
||||
button.extraSettingsEl.addClass("view-order-button");
|
||||
});
|
||||
|
||||
// Delete button - ONLY for custom views
|
||||
if (view.type === "custom") {
|
||||
viewSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Delete View"))
|
||||
.onClick(() => {
|
||||
// TODO: Add confirmation modal before deleting
|
||||
settingTab.plugin.settings.viewConfiguration.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
// No need to delete from customViewDefinitions anymore
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList();
|
||||
});
|
||||
button.extraSettingsEl.addClass("view-delete-button");
|
||||
});
|
||||
}
|
||||
|
||||
// Add new view icon
|
||||
const fragement = document.createDocumentFragment();
|
||||
const icon = fragement.createEl("i", {
|
||||
cls: "view-icon",
|
||||
});
|
||||
setIcon(icon, view.icon);
|
||||
viewSetting.settingEl.prepend(fragement);
|
||||
});
|
||||
};
|
||||
|
||||
renderViewList(); // Initial render
|
||||
|
||||
// Add New Custom View Button (Logic unchanged)
|
||||
const addBtnContainer = containerEl.createDiv();
|
||||
new Setting(addBtnContainer).addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add Custom View"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
new ViewConfigModal(
|
||||
settingTab.app,
|
||||
settingTab.plugin,
|
||||
null,
|
||||
null,
|
||||
(createdView: ViewConfig, createdRules: ViewFilterRule) => {
|
||||
if (
|
||||
!settingTab.plugin.settings.viewConfiguration.some(
|
||||
(v) => v.id === createdView.id
|
||||
)
|
||||
) {
|
||||
// Save with filter rules embedded
|
||||
settingTab.plugin.settings.viewConfiguration.push({
|
||||
...createdView,
|
||||
filterRules: createdRules,
|
||||
});
|
||||
settingTab.applySettingsUpdate();
|
||||
renderViewList();
|
||||
} else {
|
||||
new Notice(t("Error: View ID already exists."));
|
||||
}
|
||||
}
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Keep Rebuild Index ---
|
||||
new Setting(containerEl)
|
||||
.setName(t("Rebuild index"))
|
||||
.setClass("mod-warning")
|
||||
.addButton((button) => {
|
||||
button.setButtonText(t("Rebuild")).onClick(async () => {
|
||||
new ConfirmModal(settingTab.plugin, {
|
||||
title: t("Reindex"),
|
||||
message: t(
|
||||
"Are you sure you want to force reindex all tasks?"
|
||||
),
|
||||
confirmText: t("Reindex"),
|
||||
cancelText: t("Cancel"),
|
||||
onConfirm: async (confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
new Notice(
|
||||
t("Clearing task cache and rebuilding index...")
|
||||
);
|
||||
await settingTab.plugin.taskManager.forceReindex();
|
||||
new Notice(t("Task index completely rebuilt"));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to force reindex tasks:",
|
||||
error
|
||||
);
|
||||
new Notice(t("Failed to force reindex tasks"));
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
});
|
||||
});
|
||||
}
|
||||
404
src/components/settings/WorkflowSettingsTab.ts
Normal file
404
src/components/settings/WorkflowSettingsTab.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { Setting, Modal } from "obsidian";
|
||||
import { t } from "../../translations/helper";
|
||||
import { TaskProgressBarSettingTab } from "../../setting";
|
||||
import { WorkflowDefinitionModal } from "../WorkflowDefinitionModal";
|
||||
import { generateUniqueId } from "src/utils/common";
|
||||
|
||||
export function renderWorkflowSettingsTab(
|
||||
settingTab: TaskProgressBarSettingTab,
|
||||
containerEl: HTMLElement
|
||||
) {
|
||||
new Setting(containerEl)
|
||||
.setName(t("Workflow"))
|
||||
.setDesc(
|
||||
t("Configure task workflows for project and process management")
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Enable workflow"))
|
||||
.setDesc(t("Toggle to enable the workflow system for tasks"))
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.workflow.enableWorkflow)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.enableWorkflow = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
if (!settingTab.plugin.settings.workflow.enableWorkflow) return;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Auto-add timestamp"))
|
||||
.setDesc(
|
||||
t("Automatically add a timestamp to the task when it is created")
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.workflow.autoAddTimestamp)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.autoAddTimestamp =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.workflow.autoAddTimestamp) {
|
||||
let fragment = document.createDocumentFragment();
|
||||
fragment.createEl("span", {
|
||||
text: t("Timestamp format:"),
|
||||
});
|
||||
fragment.createEl("span", {
|
||||
text: " ",
|
||||
});
|
||||
const span = fragment.createEl("span");
|
||||
new Setting(containerEl)
|
||||
.setName(t("Timestamp format"))
|
||||
.setDesc(fragment)
|
||||
.addMomentFormat((format) => {
|
||||
format.setSampleEl(span);
|
||||
format.setDefaultFormat(
|
||||
settingTab.plugin.settings.workflow.timestampFormat ||
|
||||
"YYYY-MM-DD HH:mm:ss"
|
||||
);
|
||||
format
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow.timestampFormat ||
|
||||
"YYYY-MM-DD HH:mm:ss"
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.workflow.timestampFormat =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
format.updateSample();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Remove timestamp when moving to next stage"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Remove the timestamp from the current task when moving to the next stage"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow
|
||||
.removeTimestampOnTransition
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.removeTimestampOnTransition =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Calculate spent time"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Calculate and display the time spent on the task when moving to the next stage"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow.calculateSpentTime
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.calculateSpentTime =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
settingTab.display();
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
if (settingTab.plugin.settings.workflow.calculateSpentTime) {
|
||||
let fragment = document.createDocumentFragment();
|
||||
fragment.createEl("span", {
|
||||
text: t("Format for spent time:"),
|
||||
});
|
||||
fragment.createEl("span", {
|
||||
text: " ",
|
||||
});
|
||||
const span = fragment.createEl("span", {
|
||||
text: "HH:mm:ss",
|
||||
});
|
||||
fragment.createEl("span", {
|
||||
text: ". ",
|
||||
});
|
||||
fragment.createEl("span", {
|
||||
text: t("Calculate spent time when move to next stage."),
|
||||
});
|
||||
new Setting(containerEl)
|
||||
.setName(t("Spent time format"))
|
||||
.setDesc(fragment)
|
||||
.addMomentFormat((format) => {
|
||||
format.setSampleEl(span);
|
||||
format.setDefaultFormat(
|
||||
settingTab.plugin.settings.workflow.spentTimeFormat ||
|
||||
"HH:mm:ss"
|
||||
);
|
||||
format
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow
|
||||
.spentTimeFormat || "HH:mm:ss"
|
||||
)
|
||||
.onChange((value) => {
|
||||
settingTab.plugin.settings.workflow.spentTimeFormat =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
|
||||
format.updateSample();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Calculate full spent time"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Calculate the full spent time from the start of the task to the last stage"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow
|
||||
.calculateFullSpentTime
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.calculateFullSpentTime =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Auto remove last stage marker"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Automatically remove the last stage marker when a task is completed"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(
|
||||
settingTab.plugin.settings.workflow
|
||||
.autoRemoveLastStageMarker
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.autoRemoveLastStageMarker =
|
||||
value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("Auto-add next task"))
|
||||
.setDesc(
|
||||
t(
|
||||
"Automatically create a new task with the next stage when completing a task"
|
||||
)
|
||||
)
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(settingTab.plugin.settings.workflow.autoAddNextTask)
|
||||
.onChange(async (value) => {
|
||||
settingTab.plugin.settings.workflow.autoAddNextTask = value;
|
||||
settingTab.applySettingsUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
// Workflow definitions list
|
||||
new Setting(containerEl)
|
||||
.setName(t("Workflow definitions"))
|
||||
.setDesc(
|
||||
t("Configure workflow templates for different types of processes")
|
||||
);
|
||||
|
||||
// Create a container for the workflow list
|
||||
const workflowContainer = containerEl.createDiv({
|
||||
cls: "workflow-container",
|
||||
});
|
||||
|
||||
// Function to display workflow list
|
||||
const refreshWorkflowList = () => {
|
||||
// Clear the container
|
||||
workflowContainer.empty();
|
||||
|
||||
const workflows = settingTab.plugin.settings.workflow.definitions;
|
||||
|
||||
if (workflows.length === 0) {
|
||||
workflowContainer.createEl("div", {
|
||||
cls: "no-workflows-message",
|
||||
text: t(
|
||||
"No workflow definitions created yet. Click 'Add New Workflow' to create one."
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add each workflow in the list
|
||||
workflows.forEach((workflow, index) => {
|
||||
const workflowRow = workflowContainer.createDiv({
|
||||
cls: "workflow-row",
|
||||
});
|
||||
|
||||
const workflowSetting = new Setting(workflowRow)
|
||||
.setName(workflow.name)
|
||||
.setDesc(workflow.description || "");
|
||||
|
||||
// Add edit button
|
||||
workflowSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("pencil")
|
||||
.setTooltip(t("Edit workflow"))
|
||||
.onClick(() => {
|
||||
new WorkflowDefinitionModal(
|
||||
settingTab.app,
|
||||
settingTab.plugin,
|
||||
workflow,
|
||||
(updatedWorkflow) => {
|
||||
// Update the workflow
|
||||
settingTab.plugin.settings.workflow.definitions[
|
||||
index
|
||||
] = updatedWorkflow;
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshWorkflowList();
|
||||
}
|
||||
).open();
|
||||
});
|
||||
});
|
||||
|
||||
// Add delete button
|
||||
workflowSetting.addExtraButton((button) => {
|
||||
button
|
||||
.setIcon("trash")
|
||||
.setTooltip(t("Remove workflow"))
|
||||
.onClick(() => {
|
||||
// Show confirmation dialog
|
||||
const modal = new Modal(settingTab.app);
|
||||
modal.titleEl.setText(t("Delete workflow"));
|
||||
|
||||
const content = modal.contentEl.createDiv();
|
||||
content.setText(
|
||||
t(
|
||||
`Are you sure you want to delete the '${workflow.name}' workflow?`
|
||||
)
|
||||
);
|
||||
|
||||
const buttonContainer = modal.contentEl.createDiv({
|
||||
cls: "tg-modal-button-container modal-button-container",
|
||||
});
|
||||
|
||||
const cancelButton = buttonContainer.createEl("button");
|
||||
cancelButton.setText(t("Cancel"));
|
||||
cancelButton.addEventListener("click", () => {
|
||||
modal.close();
|
||||
});
|
||||
|
||||
const deleteButton = buttonContainer.createEl("button");
|
||||
deleteButton.setText(t("Delete"));
|
||||
deleteButton.addClass("mod-warning");
|
||||
deleteButton.addEventListener("click", () => {
|
||||
// Remove the workflow
|
||||
settingTab.plugin.settings.workflow.definitions.splice(
|
||||
index,
|
||||
1
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshWorkflowList();
|
||||
modal.close();
|
||||
});
|
||||
|
||||
modal.open();
|
||||
});
|
||||
});
|
||||
|
||||
// Show stage information
|
||||
const stagesInfo = workflowRow.createDiv({
|
||||
cls: "workflow-stages-info",
|
||||
});
|
||||
|
||||
if (workflow.stages.length > 0) {
|
||||
const stagesList = stagesInfo.createEl("ul");
|
||||
stagesList.addClass("workflow-stages-list");
|
||||
|
||||
workflow.stages.forEach((stage) => {
|
||||
const stageItem = stagesList.createEl("li");
|
||||
stageItem.addClass("workflow-stage-item");
|
||||
stageItem.addClass(`workflow-stage-type-${stage.type}`);
|
||||
|
||||
const stageName = stageItem.createSpan({
|
||||
text: stage.name,
|
||||
});
|
||||
|
||||
if (stage.type === "cycle") {
|
||||
stageItem.addClass("workflow-stage-cycle");
|
||||
stageName.addClass("workflow-stage-name-cycle");
|
||||
} else if (stage.type === "terminal") {
|
||||
stageItem.addClass("workflow-stage-terminal");
|
||||
stageName.addClass("workflow-stage-name-terminal");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add button to create a new workflow
|
||||
const addButtonContainer = workflowContainer.createDiv();
|
||||
new Setting(addButtonContainer).addButton((button) => {
|
||||
button
|
||||
.setButtonText(t("Add New Workflow"))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
// Create a new empty workflow
|
||||
const newWorkflow = {
|
||||
id: generateUniqueId(),
|
||||
name: t("New Workflow"),
|
||||
description: "",
|
||||
stages: [],
|
||||
metadata: {
|
||||
version: "1.0",
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
lastModified: new Date()
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
// Show the edit modal for the new workflow
|
||||
new WorkflowDefinitionModal(
|
||||
settingTab.app,
|
||||
settingTab.plugin,
|
||||
newWorkflow,
|
||||
(createdWorkflow) => {
|
||||
// Add the workflow to the list
|
||||
settingTab.plugin.settings.workflow.definitions.push(
|
||||
createdWorkflow
|
||||
);
|
||||
settingTab.applySettingsUpdate();
|
||||
refreshWorkflowList();
|
||||
}
|
||||
).open();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Initial render of the workflow list
|
||||
refreshWorkflowList();
|
||||
}
|
||||
15
src/components/settings/index.ts
Normal file
15
src/components/settings/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export { renderAboutSettingsTab } from "./AboutSettingsTab";
|
||||
export { renderBetaTestSettingsTab } from "./BetaTestSettingsTab";
|
||||
export { renderHabitSettingsTab } from "./HabitSettingsTab";
|
||||
export { IcsSettingsComponent } from "./IcsSettingsTab";
|
||||
export { renderProgressSettingsTab } from "./ProgressSettingsTab";
|
||||
export { renderQuickCaptureSettingsTab } from "./QuickCaptureSettingsTab";
|
||||
export { renderRewardSettingsTab } from "./RewardSettingsTab";
|
||||
export { renderTaskFilterSettingsTab } from "./TaskFilterSettingsTab";
|
||||
export { renderTaskHandlerSettingsTab } from "./TaskHandlerSettingsTab";
|
||||
export { renderTaskStatusSettingsTab } from "./TaskStatusSettingsTab";
|
||||
export { renderViewSettingsTab } from "./ViewSettingsTab";
|
||||
export { renderWorkflowSettingsTab } from "./WorkflowSettingsTab";
|
||||
export { renderProjectSettingsTab } from "./ProjectSettingsTab";
|
||||
export { renderDatePrioritySettingsTab } from "./DatePrioritySettingsTab";
|
||||
export { renderTimelineSidebarSettingsTab } from "./TimelineSidebarSettingsTab";
|
||||
|
|
@ -6,6 +6,7 @@ import { DatePickerPopover } from "../date-picker/DatePickerPopover";
|
|||
import type TaskProgressBarPlugin from "../../index";
|
||||
import { ContextSuggest, ProjectSuggest, TagSuggest } from "../AutoComplete";
|
||||
import { clearAllMarks } from "../MarkdownRenderer";
|
||||
import { getEffectiveProject, isProjectReadonly } from "../../utils/taskUtil";
|
||||
|
||||
// Cache for autocomplete data to avoid repeated expensive operations
|
||||
interface AutoCompleteCache {
|
||||
|
|
@ -517,7 +518,7 @@ export class TableRenderer extends Component {
|
|||
if (column.id === "rowNumber") {
|
||||
this.renderTreeStructure(td, row, cell, column);
|
||||
} else {
|
||||
this.renderCellContent(td, cell, column);
|
||||
this.renderCellContent(td, cell, column, row);
|
||||
}
|
||||
|
||||
if (cell.className) {
|
||||
|
|
@ -688,7 +689,7 @@ export class TableRenderer extends Component {
|
|||
);
|
||||
|
||||
// Render the actual cell content
|
||||
this.renderCellContent(contentWrapper, cell, column);
|
||||
this.renderCellContent(contentWrapper, cell, column, row);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -697,7 +698,8 @@ export class TableRenderer extends Component {
|
|||
private renderCellContent(
|
||||
cellEl: HTMLElement,
|
||||
cell: TableCell,
|
||||
column: TableColumn
|
||||
column: TableColumn,
|
||||
row?: TableRow
|
||||
) {
|
||||
cellEl.empty();
|
||||
|
||||
|
|
@ -718,7 +720,7 @@ export class TableRenderer extends Component {
|
|||
this.renderNumberCell(cellEl, cell);
|
||||
break;
|
||||
default:
|
||||
this.renderTextCell(cellEl, cell);
|
||||
this.renderTextCell(cellEl, cell, row);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1165,16 +1167,37 @@ export class TableRenderer extends Component {
|
|||
/**
|
||||
* Render text cell with inline editing and auto-suggest
|
||||
*/
|
||||
private renderTextCell(cellEl: HTMLElement, cell: TableCell) {
|
||||
private renderTextCell(
|
||||
cellEl: HTMLElement,
|
||||
cell: TableCell,
|
||||
row?: TableRow
|
||||
) {
|
||||
cellEl.addClass("task-table-text");
|
||||
|
||||
// For content column (rowNumber), use cleaned content without tags and other marks
|
||||
const isContentColumn = cell.columnId === "content";
|
||||
const displayText = isContentColumn
|
||||
? clearAllMarks((cell.value as string) || cell.displayValue)
|
||||
: cell.displayValue;
|
||||
const isProjectColumn = cell.columnId === "project";
|
||||
|
||||
if (cell.editable) {
|
||||
// Get effective project value for project column
|
||||
let displayText: string;
|
||||
let effectiveValue: string;
|
||||
let isReadonly = false;
|
||||
|
||||
if (isProjectColumn && row?.task?.metadata?.tgProject) {
|
||||
effectiveValue = getEffectiveProject(row.task) || "";
|
||||
displayText = effectiveValue;
|
||||
isReadonly = isProjectReadonly(row.task);
|
||||
} else if (isContentColumn) {
|
||||
displayText = clearAllMarks(
|
||||
(cell.value as string) || cell.displayValue
|
||||
);
|
||||
effectiveValue = displayText;
|
||||
} else {
|
||||
displayText = cell.displayValue;
|
||||
effectiveValue = (cell.value as string) || "";
|
||||
}
|
||||
|
||||
if (cell.editable && !isReadonly) {
|
||||
// Create editable input
|
||||
const input = cellEl.createEl("input", "task-table-text-input");
|
||||
input.type = "text";
|
||||
|
|
@ -1186,7 +1209,7 @@ export class TableRenderer extends Component {
|
|||
// For content column, use the cleaned text; for others, use the raw value
|
||||
const originalValue = isContentColumn
|
||||
? displayText // This is the cleaned text that user sees and edits
|
||||
: (cell.value as string) || "";
|
||||
: effectiveValue;
|
||||
|
||||
// Setup autocomplete only when user starts typing or focuses
|
||||
let autoCompleteSetup = false;
|
||||
|
|
@ -1249,6 +1272,52 @@ export class TableRenderer extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Add tgProject indicator for project column
|
||||
if (isProjectColumn && row?.task?.metadata?.tgProject) {
|
||||
const tgProject = row.task.metadata.tgProject;
|
||||
const indicator = cellEl.createDiv({
|
||||
cls: "project-source-indicator table-indicator",
|
||||
});
|
||||
|
||||
// Create indicator icon based on tgProject type
|
||||
let indicatorIcon = "";
|
||||
let indicatorTitle = "";
|
||||
|
||||
switch (tgProject.type) {
|
||||
case "path":
|
||||
indicatorIcon = "📁";
|
||||
indicatorTitle =
|
||||
t("Auto-assigned from path") + `: ${tgProject.source}`;
|
||||
break;
|
||||
case "metadata":
|
||||
indicatorIcon = "📄";
|
||||
indicatorTitle =
|
||||
t("Auto-assigned from file metadata") +
|
||||
`: ${tgProject.source}`;
|
||||
break;
|
||||
case "config":
|
||||
indicatorIcon = "⚙️";
|
||||
indicatorTitle =
|
||||
t("Auto-assigned from config file") +
|
||||
`: ${tgProject.source}`;
|
||||
break;
|
||||
default:
|
||||
indicatorIcon = "🔗";
|
||||
indicatorTitle =
|
||||
t("Auto-assigned") + `: ${tgProject.source}`;
|
||||
}
|
||||
|
||||
indicator.innerHTML = `<span class="indicator-icon">${indicatorIcon}</span>`;
|
||||
indicator.title = indicatorTitle;
|
||||
|
||||
if (isReadonly) {
|
||||
indicator.addClass("readonly-indicator");
|
||||
cellEl.addClass("readonly-cell");
|
||||
} else {
|
||||
indicator.addClass("override-indicator");
|
||||
}
|
||||
}
|
||||
|
||||
// Add tooltip for long text - only if necessary
|
||||
if (displayText.length > 50) {
|
||||
cellEl.title = displayText;
|
||||
|
|
@ -1452,7 +1521,7 @@ export class TableRenderer extends Component {
|
|||
/**
|
||||
* Get all available values for auto-completion from existing tasks
|
||||
*/
|
||||
private getAllValues(columnType: string): string[] {
|
||||
private async getAllValues(columnType: string): Promise<string[]> {
|
||||
if (!this.plugin) return [];
|
||||
|
||||
// Get all tasks from the plugin
|
||||
|
|
@ -1462,7 +1531,7 @@ export class TableRenderer extends Component {
|
|||
allTasks.forEach((task) => {
|
||||
switch (columnType) {
|
||||
case "tags":
|
||||
task.tags?.forEach((tag) => {
|
||||
task.metadata.tags?.forEach((tag) => {
|
||||
if (tag && tag.trim()) {
|
||||
// Remove # prefix if present
|
||||
const cleanTag = tag.startsWith("#")
|
||||
|
|
@ -1473,13 +1542,13 @@ export class TableRenderer extends Component {
|
|||
});
|
||||
break;
|
||||
case "project":
|
||||
if (task.project && task.project.trim()) {
|
||||
values.add(task.project);
|
||||
if (task.metadata.project && task.metadata.project.trim()) {
|
||||
values.add(task.metadata.project);
|
||||
}
|
||||
break;
|
||||
case "context":
|
||||
if (task.context && task.context.trim()) {
|
||||
values.add(task.context);
|
||||
if (task.metadata.context && task.metadata.context.trim()) {
|
||||
values.add(task.metadata.context);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { TreeManager } from "./TreeManager";
|
|||
import { VirtualScrollManager } from "./VirtualScrollManager";
|
||||
import { TableHeader, TableHeaderCallbacks } from "./TableHeader";
|
||||
import { sortTasks } from "../../commands/sortTaskCommands";
|
||||
import { isProjectReadonly } from "../../utils/taskUtil";
|
||||
import "../../styles/table.css";
|
||||
|
||||
export interface TableViewCallbacks {
|
||||
|
|
@ -529,44 +530,44 @@ export class TableView extends Component {
|
|||
displayValue = task.content;
|
||||
break;
|
||||
case "priority":
|
||||
value = task.priority;
|
||||
displayValue = this.formatPriority(task.priority);
|
||||
value = task.metadata.priority;
|
||||
displayValue = this.formatPriority(task.metadata.priority);
|
||||
break;
|
||||
case "dueDate":
|
||||
value = task.dueDate;
|
||||
displayValue = this.formatDate(task.dueDate);
|
||||
value = task.metadata.dueDate;
|
||||
displayValue = this.formatDate(task.metadata.dueDate);
|
||||
break;
|
||||
case "startDate":
|
||||
value = task.startDate;
|
||||
displayValue = this.formatDate(task.startDate);
|
||||
value = task.metadata.startDate;
|
||||
displayValue = this.formatDate(task.metadata.startDate);
|
||||
break;
|
||||
case "scheduledDate":
|
||||
value = task.scheduledDate;
|
||||
displayValue = this.formatDate(task.scheduledDate);
|
||||
value = task.metadata.scheduledDate;
|
||||
displayValue = this.formatDate(task.metadata.scheduledDate);
|
||||
break;
|
||||
case "createdDate":
|
||||
value = task.createdDate;
|
||||
displayValue = this.formatDate(task.createdDate);
|
||||
value = task.metadata.createdDate;
|
||||
displayValue = this.formatDate(task.metadata.createdDate);
|
||||
break;
|
||||
case "completedDate":
|
||||
value = task.completedDate;
|
||||
displayValue = this.formatDate(task.completedDate);
|
||||
value = task.metadata.completedDate;
|
||||
displayValue = this.formatDate(task.metadata.completedDate);
|
||||
break;
|
||||
case "tags":
|
||||
value = task.tags;
|
||||
displayValue = task.tags?.join(", ") || "";
|
||||
value = task.metadata.tags;
|
||||
displayValue = task.metadata.tags?.join(", ") || "";
|
||||
break;
|
||||
case "project":
|
||||
value = task.project;
|
||||
displayValue = task.project || "";
|
||||
value = task.metadata.project;
|
||||
displayValue = task.metadata.project || "";
|
||||
break;
|
||||
case "context":
|
||||
value = task.context;
|
||||
displayValue = task.context || "";
|
||||
value = task.metadata.context;
|
||||
displayValue = task.metadata.context || "";
|
||||
break;
|
||||
case "recurrence":
|
||||
value = task.recurrence;
|
||||
displayValue = task.recurrence || "";
|
||||
value = task.metadata.recurrence;
|
||||
displayValue = task.metadata.recurrence || "";
|
||||
break;
|
||||
case "filePath":
|
||||
value = task.filePath;
|
||||
|
|
@ -751,18 +752,23 @@ export class TableView extends Component {
|
|||
this.updateSortIndicators();
|
||||
|
||||
// Debug logging to help identify sorting issues
|
||||
console.log(`Table sorted by ${this.currentSortField} (${this.currentSortOrder})`);
|
||||
console.log(
|
||||
`Table sorted by ${this.currentSortField} (${this.currentSortOrder})`
|
||||
);
|
||||
console.log(`Filtered tasks count: ${this.filteredTasks.length}`);
|
||||
console.log(`Displayed rows count: ${this.displayedRows.length}`);
|
||||
|
||||
// Fallback: If the table doesn't seem to be updating properly, force a complete refresh
|
||||
// This is a safety net for any edge cases in the rendering logic
|
||||
setTimeout(() => {
|
||||
const currentRowCount = this.bodyEl.querySelectorAll("tr[data-row-id]").length;
|
||||
const currentRowCount =
|
||||
this.bodyEl.querySelectorAll("tr[data-row-id]").length;
|
||||
const expectedRowCount = this.displayedRows.length;
|
||||
|
||||
if (currentRowCount !== expectedRowCount && expectedRowCount > 0) {
|
||||
console.warn(`Table row count mismatch detected. Expected: ${expectedRowCount}, Actual: ${currentRowCount}. Forcing refresh.`);
|
||||
console.warn(
|
||||
`Table row count mismatch detected. Expected: ${expectedRowCount}, Actual: ${currentRowCount}. Forcing refresh.`
|
||||
);
|
||||
this.forceRefresh();
|
||||
}
|
||||
}, 100); // Small delay to allow rendering to complete
|
||||
|
|
@ -940,52 +946,61 @@ export class TableView extends Component {
|
|||
task.content = value;
|
||||
break;
|
||||
case "priority":
|
||||
task.priority = value ? parseInt(String(value)) : undefined;
|
||||
task.metadata.priority = value
|
||||
? parseInt(String(value))
|
||||
: undefined;
|
||||
break;
|
||||
case "dueDate":
|
||||
task.dueDate = value ? new Date(value).getTime() : undefined;
|
||||
task.metadata.dueDate = value
|
||||
? new Date(value).getTime()
|
||||
: undefined;
|
||||
break;
|
||||
case "startDate":
|
||||
task.startDate = value ? new Date(value).getTime() : undefined;
|
||||
task.metadata.startDate = value
|
||||
? new Date(value).getTime()
|
||||
: undefined;
|
||||
break;
|
||||
case "scheduledDate":
|
||||
task.scheduledDate = value
|
||||
task.metadata.scheduledDate = value
|
||||
? new Date(value).getTime()
|
||||
: undefined;
|
||||
break;
|
||||
case "createdDate":
|
||||
task.createdDate = value
|
||||
task.metadata.createdDate = value
|
||||
? new Date(value).getTime()
|
||||
: undefined;
|
||||
break;
|
||||
case "completedDate":
|
||||
task.completedDate = value
|
||||
task.metadata.completedDate = value
|
||||
? new Date(value).getTime()
|
||||
: undefined;
|
||||
break;
|
||||
case "tags":
|
||||
// Handle both array and string inputs
|
||||
if (Array.isArray(value)) {
|
||||
task.tags = value;
|
||||
task.metadata.tags = value;
|
||||
} else if (typeof value === "string") {
|
||||
task.tags = value
|
||||
task.metadata.tags = value
|
||||
? value
|
||||
.split(",")
|
||||
.map((t: string) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: [];
|
||||
} else {
|
||||
task.tags = [];
|
||||
task.metadata.tags = [];
|
||||
}
|
||||
break;
|
||||
case "project":
|
||||
task.project = value || undefined;
|
||||
// Only update project if it's not a read-only tgProject
|
||||
if (!isProjectReadonly(task)) {
|
||||
task.metadata.project = value || undefined;
|
||||
}
|
||||
break;
|
||||
case "context":
|
||||
task.context = value || undefined;
|
||||
task.metadata.context = value || undefined;
|
||||
break;
|
||||
case "recurrence":
|
||||
task.recurrence = value || undefined;
|
||||
task.metadata.recurrence = value || undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1250,44 +1265,27 @@ export class TableView extends Component {
|
|||
// Update task property based on column
|
||||
const updatedTask = { ...task };
|
||||
|
||||
// Define valid date column IDs for type safety
|
||||
const dateColumns = [
|
||||
"dueDate",
|
||||
"startDate",
|
||||
"scheduledDate",
|
||||
"createdDate",
|
||||
"completedDate",
|
||||
] as const;
|
||||
|
||||
// Check if the column is a valid date column
|
||||
if (!dateColumns.includes(columnId as any)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDate) {
|
||||
// Set the date value
|
||||
const dateValue = new Date(newDate).getTime();
|
||||
switch (columnId) {
|
||||
case "dueDate":
|
||||
updatedTask.dueDate = dateValue;
|
||||
break;
|
||||
case "startDate":
|
||||
updatedTask.startDate = dateValue;
|
||||
break;
|
||||
case "scheduledDate":
|
||||
updatedTask.scheduledDate = dateValue;
|
||||
break;
|
||||
case "createdDate":
|
||||
updatedTask.createdDate = dateValue;
|
||||
break;
|
||||
case "completedDate":
|
||||
updatedTask.completedDate = dateValue;
|
||||
break;
|
||||
}
|
||||
(updatedTask.metadata as any)[columnId] = dateValue;
|
||||
} else {
|
||||
// Clear the date
|
||||
switch (columnId) {
|
||||
case "dueDate":
|
||||
delete updatedTask.dueDate;
|
||||
break;
|
||||
case "startDate":
|
||||
delete updatedTask.startDate;
|
||||
break;
|
||||
case "scheduledDate":
|
||||
delete updatedTask.scheduledDate;
|
||||
break;
|
||||
case "createdDate":
|
||||
delete updatedTask.createdDate;
|
||||
break;
|
||||
case "completedDate":
|
||||
delete updatedTask.completedDate;
|
||||
break;
|
||||
}
|
||||
delete (updatedTask.metadata as any)[columnId];
|
||||
}
|
||||
|
||||
// Notify task update
|
||||
|
|
|
|||
|
|
@ -92,9 +92,12 @@ export class TreeManager extends Component {
|
|||
const node = this.treeNodes.get(task.id);
|
||||
if (!node) return;
|
||||
|
||||
if (task.parent && this.treeNodes.has(task.parent)) {
|
||||
if (
|
||||
task.metadata.parent &&
|
||||
this.treeNodes.has(task.metadata.parent)
|
||||
) {
|
||||
// This task has a parent
|
||||
const parentNode = this.treeNodes.get(task.parent);
|
||||
const parentNode = this.treeNodes.get(task.metadata.parent);
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node);
|
||||
node.parent = parentNode;
|
||||
|
|
@ -185,17 +188,24 @@ export class TreeManager extends Component {
|
|||
*/
|
||||
private fallbackSort(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
// Priority comparison (higher priority first)
|
||||
const aPriority = a.priority || 0;
|
||||
const bPriority = b.priority || 0;
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // Higher priority first
|
||||
// 优先级比较(高优先级在前)
|
||||
const priorityDiff =
|
||||
(b.metadata.priority ?? 0) - (a.metadata.priority ?? 0);
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
// Creation date comparison (newer first)
|
||||
const aCreated = a.createdDate || 0;
|
||||
const bCreated = b.createdDate || 0;
|
||||
return bCreated - aCreated;
|
||||
// 创建日期比较(新任务在前)
|
||||
const createdDiff =
|
||||
(b.metadata.createdDate ?? 0) - (a.metadata.createdDate ?? 0);
|
||||
if (createdDiff !== 0) {
|
||||
return createdDiff;
|
||||
}
|
||||
|
||||
// 如果优先级和创建日期都相同,按内容字母顺序排序
|
||||
const contentA = a.content?.trim() || "";
|
||||
const contentB = b.content?.trim() || "";
|
||||
return contentA.localeCompare(contentB);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -251,52 +261,53 @@ export class TreeManager extends Component {
|
|||
displayValue = task.content;
|
||||
break;
|
||||
case "priority":
|
||||
value = task.priority;
|
||||
displayValue = this.formatPriority(task.priority);
|
||||
value = task.metadata.priority;
|
||||
displayValue = this.formatPriority(task.metadata.priority);
|
||||
break;
|
||||
case "dueDate":
|
||||
value = task.dueDate;
|
||||
displayValue = this.formatDate(task.dueDate);
|
||||
value = task.metadata.dueDate;
|
||||
displayValue = this.formatDate(task.metadata.dueDate);
|
||||
break;
|
||||
case "startDate":
|
||||
value = task.startDate;
|
||||
displayValue = this.formatDate(task.startDate);
|
||||
value = task.metadata.startDate;
|
||||
displayValue = this.formatDate(task.metadata.startDate);
|
||||
break;
|
||||
case "scheduledDate":
|
||||
value = task.scheduledDate;
|
||||
displayValue = this.formatDate(task.scheduledDate);
|
||||
value = task.metadata.scheduledDate;
|
||||
displayValue = this.formatDate(task.metadata.scheduledDate);
|
||||
break;
|
||||
case "createdDate":
|
||||
value = task.createdDate;
|
||||
displayValue = this.formatDate(task.createdDate);
|
||||
value = task.metadata.createdDate;
|
||||
displayValue = this.formatDate(task.metadata.createdDate);
|
||||
break;
|
||||
case "completedDate":
|
||||
value = task.completedDate;
|
||||
displayValue = this.formatDate(task.completedDate);
|
||||
value = task.metadata.completedDate;
|
||||
displayValue = this.formatDate(task.metadata.completedDate);
|
||||
break;
|
||||
case "tags":
|
||||
value = task.tags;
|
||||
displayValue = task.tags?.join(", ") || "";
|
||||
value = task.metadata.tags;
|
||||
displayValue = task.metadata.tags?.join(", ") || "";
|
||||
break;
|
||||
case "project":
|
||||
value = task.project;
|
||||
displayValue = task.project || "";
|
||||
value = task.metadata.project;
|
||||
displayValue = task.metadata.project || "";
|
||||
break;
|
||||
case "context":
|
||||
value = task.context;
|
||||
displayValue = task.context || "";
|
||||
value = task.metadata.context;
|
||||
displayValue = task.metadata.context || "";
|
||||
break;
|
||||
case "recurrence":
|
||||
value = task.recurrence;
|
||||
displayValue = task.recurrence || "";
|
||||
value = task.metadata.recurrence;
|
||||
displayValue = task.metadata.recurrence || "";
|
||||
break;
|
||||
case "estimatedTime":
|
||||
value = task.estimatedTime;
|
||||
displayValue = task.estimatedTime?.toString() || "";
|
||||
value = task.metadata.estimatedTime;
|
||||
displayValue =
|
||||
task.metadata.estimatedTime?.toString() || "";
|
||||
break;
|
||||
case "actualTime":
|
||||
value = task.actualTime;
|
||||
displayValue = task.actualTime?.toString() || "";
|
||||
value = task.metadata.actualTime;
|
||||
displayValue = task.metadata.actualTime?.toString() || "";
|
||||
break;
|
||||
case "filePath":
|
||||
value = task.filePath;
|
||||
|
|
@ -457,7 +468,7 @@ export class TreeManager extends Component {
|
|||
}
|
||||
|
||||
// Update task's parent property
|
||||
node.task.parent = newParentId || undefined;
|
||||
node.task.metadata.parent = newParentId || undefined;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { t } from "../../translations/helper";
|
|||
import { ProjectSuggest, TagSuggest, ContextSuggest } from "../AutoComplete";
|
||||
import { StatusComponent } from "../StatusComponent";
|
||||
import { format } from "date-fns";
|
||||
import { getEffectiveProject, isProjectReadonly } from "../../utils/taskUtil";
|
||||
|
||||
export interface MetadataChangeEvent {
|
||||
field: string;
|
||||
|
|
@ -143,7 +144,7 @@ export class TaskMetadataEditor extends Component {
|
|||
pane,
|
||||
t("Due Date"),
|
||||
"dueDate",
|
||||
this.getDateString(this.task.dueDate)
|
||||
this.getDateString(this.task.metadata.dueDate)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -152,13 +153,13 @@ export class TaskMetadataEditor extends Component {
|
|||
pane,
|
||||
t("Start Date"),
|
||||
"startDate",
|
||||
this.getDateString(this.task.startDate)
|
||||
this.getDateString(this.task.metadata.startDate)
|
||||
);
|
||||
this.createDateEditor(
|
||||
pane,
|
||||
t("Scheduled Date"),
|
||||
"scheduledDate",
|
||||
this.getDateString(this.task.scheduledDate)
|
||||
this.getDateString(this.task.metadata.scheduledDate)
|
||||
);
|
||||
this.createRecurrenceEditor(pane);
|
||||
}
|
||||
|
|
@ -201,19 +202,19 @@ export class TaskMetadataEditor extends Component {
|
|||
datesContainer,
|
||||
t("Due Date"),
|
||||
"dueDate",
|
||||
this.getDateString(this.task.dueDate)
|
||||
this.getDateString(this.task.metadata.dueDate)
|
||||
);
|
||||
this.createDateEditor(
|
||||
datesContainer,
|
||||
t("Start Date"),
|
||||
"startDate",
|
||||
this.getDateString(this.task.startDate)
|
||||
this.getDateString(this.task.metadata.startDate)
|
||||
);
|
||||
this.createDateEditor(
|
||||
datesContainer,
|
||||
t("Scheduled Date"),
|
||||
"scheduledDate",
|
||||
this.getDateString(this.task.scheduledDate)
|
||||
this.getDateString(this.task.metadata.scheduledDate)
|
||||
);
|
||||
|
||||
// Recurrence rule editor
|
||||
|
|
@ -280,7 +281,9 @@ export class TaskMetadataEditor extends Component {
|
|||
|
||||
priorityDropdown.selectEl.addClass("priority-select");
|
||||
|
||||
const taskPriority = this.getPriorityString(this.task.priority);
|
||||
const taskPriority = this.getPriorityString(
|
||||
this.task.metadata.priority
|
||||
);
|
||||
priorityDropdown.setValue(taskPriority || "");
|
||||
}
|
||||
|
||||
|
|
@ -338,18 +341,42 @@ export class TaskMetadataEditor extends Component {
|
|||
const fieldLabel = fieldContainer.createDiv({ cls: "field-label" });
|
||||
fieldLabel.setText(t("Project"));
|
||||
|
||||
const effectiveProject = getEffectiveProject(this.task);
|
||||
const isReadonly = isProjectReadonly(this.task);
|
||||
|
||||
const projectInput = new TextComponent(fieldContainer)
|
||||
.setPlaceholder(t("Project name"))
|
||||
.setValue(this.task.project || "")
|
||||
.setValue(effectiveProject || "")
|
||||
.setDisabled(isReadonly)
|
||||
.onChange((value) => {
|
||||
this.notifyMetadataChange("project", value);
|
||||
if (!isReadonly) {
|
||||
this.notifyMetadataChange("project", value);
|
||||
}
|
||||
});
|
||||
|
||||
// Add visual indicator for tgProject
|
||||
if (isReadonly && this.task.metadata.tgProject) {
|
||||
fieldContainer.addClass("project-readonly");
|
||||
const indicator = fieldContainer.createDiv({
|
||||
cls: "project-source-indicator",
|
||||
text: `From ${this.task.metadata.tgProject.type}: ${
|
||||
this.task.metadata.tgProject.source || ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
this.registerDomEvent(projectInput.inputEl, "blur", () => {
|
||||
this.notifyMetadataChange("project", projectInput.inputEl.value);
|
||||
if (!isReadonly) {
|
||||
this.notifyMetadataChange(
|
||||
"project",
|
||||
projectInput.inputEl.value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
new ProjectSuggest(this.app, projectInput.inputEl, this.plugin);
|
||||
if (!isReadonly) {
|
||||
new ProjectSuggest(this.app, projectInput.inputEl, this.plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -365,7 +392,9 @@ export class TaskMetadataEditor extends Component {
|
|||
const tagsInput = new TextComponent(fieldContainer)
|
||||
.setPlaceholder(t("e.g. #tag1, #tag2"))
|
||||
.setValue(
|
||||
Array.isArray(this.task.tags) ? this.task.tags.join(", ") : ""
|
||||
Array.isArray(this.task.metadata.tags)
|
||||
? this.task.metadata.tags.join(", ")
|
||||
: ""
|
||||
);
|
||||
|
||||
this.registerDomEvent(tagsInput.inputEl, "blur", () => {
|
||||
|
|
@ -392,8 +421,8 @@ export class TaskMetadataEditor extends Component {
|
|||
const contextInput = new TextComponent(fieldContainer)
|
||||
.setPlaceholder(t("e.g. @home, @work"))
|
||||
.setValue(
|
||||
Array.isArray(this.task.context)
|
||||
? this.task.context.join(", ")
|
||||
Array.isArray(this.task.metadata.context)
|
||||
? this.task.metadata.context.join(", ")
|
||||
: ""
|
||||
);
|
||||
|
||||
|
|
@ -420,7 +449,7 @@ export class TaskMetadataEditor extends Component {
|
|||
|
||||
const recurrenceInput = new TextComponent(fieldContainer)
|
||||
.setPlaceholder(t("e.g. every day, every week"))
|
||||
.setValue(this.task.recurrence || "")
|
||||
.setValue(this.task.metadata.recurrence || "")
|
||||
.onChange((value) => {
|
||||
this.notifyMetadataChange("recurrence", value);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,25 +57,44 @@ export class TaskDetailsModal extends Modal {
|
|||
|
||||
// Listen for metadata change events
|
||||
this.metadataEditor.onMetadataChange = async (event) => {
|
||||
// Determine if the field is a top-level task property or metadata property
|
||||
const topLevelFields = ["status", "completed", "content"];
|
||||
const isTopLevelField = topLevelFields.includes(event.field);
|
||||
|
||||
// Create a base task object with the updated field
|
||||
const updatedTask = {
|
||||
...this.task,
|
||||
[event.field]: event.value,
|
||||
line: this.task.line - 1,
|
||||
id: `${this.task.filePath}-L${this.task.line - 1}`,
|
||||
};
|
||||
|
||||
// Only update completed status and completedDate if the status field is changing to a completed state
|
||||
if (isTopLevelField) {
|
||||
// Update top-level task property
|
||||
(updatedTask as any)[event.field] = event.value;
|
||||
} else {
|
||||
// Update metadata property
|
||||
updatedTask.metadata = {
|
||||
...this.task.metadata,
|
||||
[event.field]: event.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special status field logic
|
||||
if (
|
||||
event.field === "status" &&
|
||||
(event.value === "x" || event.value === "X")
|
||||
) {
|
||||
updatedTask.completed = true;
|
||||
updatedTask.completedDate = Date.now();
|
||||
updatedTask.metadata = {
|
||||
...updatedTask.metadata,
|
||||
completedDate: Date.now(),
|
||||
};
|
||||
} else if (event.field === "status") {
|
||||
// If status is changing to something else, mark as not completed
|
||||
updatedTask.completed = false;
|
||||
updatedTask.completedDate = undefined;
|
||||
const { completedDate, ...metadataWithoutCompletedDate } =
|
||||
updatedTask.metadata;
|
||||
updatedTask.metadata = metadataWithoutCompletedDate;
|
||||
}
|
||||
|
||||
this.task = updatedTask;
|
||||
|
|
|
|||
|
|
@ -74,33 +74,44 @@ export class TaskDetailsPopover
|
|||
|
||||
// Listen for metadata change events
|
||||
this.metadataEditor.onMetadataChange = async (event) => {
|
||||
// Determine if the field is a top-level task property or metadata property
|
||||
const topLevelFields = ["status", "completed", "content"];
|
||||
const isTopLevelField = topLevelFields.includes(event.field);
|
||||
|
||||
// Create a base task object with the updated field
|
||||
const updatedTask = {
|
||||
...this.task,
|
||||
[event.field]: event.value,
|
||||
};
|
||||
const updatedTask = { ...this.task };
|
||||
|
||||
// Update the internal task reference
|
||||
if (isTopLevelField) {
|
||||
// Update top-level task property
|
||||
(updatedTask as any)[event.field] = event.value;
|
||||
} else {
|
||||
// Update metadata property
|
||||
updatedTask.metadata = {
|
||||
...this.task.metadata,
|
||||
[event.field]: event.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Only update completed status and completedDate if the status field is changing to a completed state
|
||||
// Handle special status field logic
|
||||
if (
|
||||
event.field === "status" &&
|
||||
(event.value === "x" || event.value === "X")
|
||||
) {
|
||||
updatedTask.completed = true;
|
||||
updatedTask.completedDate = Date.now();
|
||||
updatedTask.metadata = {
|
||||
...updatedTask.metadata,
|
||||
completedDate: Date.now(),
|
||||
};
|
||||
} else if (event.field === "status") {
|
||||
// If status is changing to something else, mark as not completed
|
||||
updatedTask.completed = false;
|
||||
updatedTask.completedDate = undefined;
|
||||
const { completedDate, ...metadataWithoutCompletedDate } =
|
||||
updatedTask.metadata;
|
||||
updatedTask.metadata = metadataWithoutCompletedDate;
|
||||
}
|
||||
|
||||
this.task = {
|
||||
...this.task,
|
||||
[event.field]: event.value,
|
||||
completed: updatedTask.completed,
|
||||
completedDate: updatedTask.completedDate,
|
||||
};
|
||||
// Update the internal task reference
|
||||
this.task = updatedTask;
|
||||
|
||||
// Update the task with all changes
|
||||
this.debounceUpdateTask(updatedTask);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, Component, debounce, setIcon, Menu } from "obsidian";
|
||||
import { Task } from "../../types/task";
|
||||
import { StandardTaskMetadata, Task } from "../../types/task";
|
||||
import TaskProgressBarPlugin from "../../index";
|
||||
import { ContextSuggest, ProjectSuggest, TagSuggest } from "../AutoComplete";
|
||||
import { clearAllMarks } from "../MarkdownRenderer";
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
EmbeddableMarkdownEditor,
|
||||
} from "../../editor-ext/markdownEditor";
|
||||
import "../../styles/inline-editor.css";
|
||||
import { getEffectiveProject, isProjectReadonly } from "../../utils/taskUtil";
|
||||
import { t } from "../../translations/helper";
|
||||
|
||||
export interface InlineEditorOptions {
|
||||
onTaskUpdate: (task: Task, updatedTask: Task) => Promise<void>;
|
||||
|
|
@ -79,7 +81,10 @@ export class InlineEditor extends Component {
|
|||
this.isSaving = false;
|
||||
|
||||
// Store original task state for potential restoration - deep clone to avoid reference issues
|
||||
this.originalTask = { ...this.task };
|
||||
this.originalTask = {
|
||||
...this.task,
|
||||
metadata: { ...this.task.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,8 +94,6 @@ export class InlineEditor extends Component {
|
|||
this.initializeEditingState();
|
||||
this.isEditing = true;
|
||||
|
||||
// Store original content before clearing
|
||||
const originalContent = targetEl.innerHTML;
|
||||
targetEl.empty();
|
||||
|
||||
// Extract the text content from the original markdown
|
||||
|
|
@ -301,13 +304,67 @@ export class InlineEditor extends Component {
|
|||
container: HTMLElement,
|
||||
currentValue?: string
|
||||
): void {
|
||||
// Get effective project and readonly status
|
||||
const effectiveProject = getEffectiveProject(this.task);
|
||||
const isReadonly = isProjectReadonly(this.task);
|
||||
|
||||
const input = container.createEl("input", {
|
||||
type: "text",
|
||||
cls: "inline-project-input",
|
||||
value: currentValue || "",
|
||||
value: effectiveProject || "",
|
||||
placeholder: "Enter project name",
|
||||
});
|
||||
|
||||
// Add visual indicator for tgProject
|
||||
if (this.task.metadata.tgProject) {
|
||||
const tgProject = this.task.metadata.tgProject;
|
||||
const indicator = container.createDiv({
|
||||
cls: "project-source-indicator inline-indicator",
|
||||
});
|
||||
|
||||
// Create indicator text based on tgProject type
|
||||
let indicatorText = "";
|
||||
let indicatorIcon = "";
|
||||
|
||||
switch (tgProject.type) {
|
||||
case "path":
|
||||
indicatorText = t("Auto from path");
|
||||
indicatorIcon = "📁";
|
||||
break;
|
||||
case "metadata":
|
||||
indicatorText = t("Auto from metadata");
|
||||
indicatorIcon = "📄";
|
||||
break;
|
||||
case "config":
|
||||
indicatorText = t("Auto from config");
|
||||
indicatorIcon = "⚙️";
|
||||
break;
|
||||
default:
|
||||
indicatorText = t("Auto-assigned");
|
||||
indicatorIcon = "🔗";
|
||||
}
|
||||
|
||||
indicator.createEl("span", {
|
||||
cls: "indicator-icon",
|
||||
text: indicatorIcon,
|
||||
});
|
||||
indicator.createEl("span", {
|
||||
cls: "indicator-text",
|
||||
text: indicatorText,
|
||||
});
|
||||
|
||||
if (isReadonly) {
|
||||
indicator.addClass("readonly-indicator");
|
||||
input.disabled = true;
|
||||
input.title = t(
|
||||
"This project is automatically assigned and cannot be changed"
|
||||
);
|
||||
} else {
|
||||
indicator.addClass("override-indicator");
|
||||
input.title = t("You can override the auto-assigned project");
|
||||
}
|
||||
}
|
||||
|
||||
this.activeInput = input;
|
||||
|
||||
// Prevent event bubbling on input element
|
||||
|
|
@ -323,13 +380,22 @@ export class InlineEditor extends Component {
|
|||
);
|
||||
|
||||
const updateProject = (value: string) => {
|
||||
this.task.project = value || undefined;
|
||||
// Only update project if it's not a read-only tgProject
|
||||
if (!isReadonly) {
|
||||
this.task.metadata.project = value || undefined;
|
||||
}
|
||||
};
|
||||
|
||||
this.setupInputEvents(input, updateProject, "project");
|
||||
|
||||
// Add autocomplete
|
||||
this.activeSuggest = new ProjectSuggest(this.app, input, this.plugin);
|
||||
// Add autocomplete only if not readonly
|
||||
if (!isReadonly) {
|
||||
this.activeSuggest = new ProjectSuggest(
|
||||
this.app,
|
||||
input,
|
||||
this.plugin
|
||||
);
|
||||
}
|
||||
|
||||
// Focus and select
|
||||
input.focus();
|
||||
|
|
@ -362,7 +428,7 @@ export class InlineEditor extends Component {
|
|||
);
|
||||
|
||||
const updateTags = (value: string) => {
|
||||
this.task.tags = value
|
||||
this.task.metadata.tags = value
|
||||
? value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
|
|
@ -406,7 +472,7 @@ export class InlineEditor extends Component {
|
|||
);
|
||||
|
||||
const updateContext = (value: string) => {
|
||||
this.task.context = value || undefined;
|
||||
this.task.metadata.context = value || undefined;
|
||||
};
|
||||
|
||||
this.setupInputEvents(input, updateContext, "context");
|
||||
|
|
@ -447,9 +513,13 @@ export class InlineEditor extends Component {
|
|||
const updateDate = (value: string) => {
|
||||
if (value) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
this.task[fieldType] = new Date(year, month - 1, day).getTime();
|
||||
this.task.metadata[fieldType] = new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day
|
||||
).getTime();
|
||||
} else {
|
||||
this.task[fieldType] = undefined;
|
||||
this.task.metadata[fieldType] = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -500,7 +570,7 @@ export class InlineEditor extends Component {
|
|||
this.activeInput = select;
|
||||
|
||||
const updatePriority = (value: string) => {
|
||||
this.task.priority = value ? parseInt(value) : undefined;
|
||||
this.task.metadata.priority = value ? parseInt(value) : undefined;
|
||||
};
|
||||
|
||||
this.setupInputEvents(select, updatePriority, "priority");
|
||||
|
|
@ -535,7 +605,7 @@ export class InlineEditor extends Component {
|
|||
);
|
||||
|
||||
const updateRecurrence = (value: string) => {
|
||||
this.task.recurrence = value || undefined;
|
||||
this.task.metadata.recurrence = value || undefined;
|
||||
};
|
||||
|
||||
this.setupInputEvents(input, updateRecurrence, "recurrence");
|
||||
|
|
@ -693,21 +763,24 @@ export class InlineEditor extends Component {
|
|||
const fieldsToShow = availableFields.filter((field) => {
|
||||
switch (field.key) {
|
||||
case "project":
|
||||
return !this.task.project;
|
||||
return !this.task.metadata.project;
|
||||
case "tags":
|
||||
return !this.task.tags || this.task.tags.length === 0;
|
||||
return (
|
||||
!this.task.metadata.tags ||
|
||||
this.task.metadata.tags.length === 0
|
||||
);
|
||||
case "context":
|
||||
return !this.task.context;
|
||||
return !this.task.metadata.context;
|
||||
case "dueDate":
|
||||
return !this.task.dueDate;
|
||||
return !this.task.metadata.dueDate;
|
||||
case "startDate":
|
||||
return !this.task.startDate;
|
||||
return !this.task.metadata.startDate;
|
||||
case "scheduledDate":
|
||||
return !this.task.scheduledDate;
|
||||
return !this.task.metadata.scheduledDate;
|
||||
case "priority":
|
||||
return !this.task.priority;
|
||||
return !this.task.metadata.priority;
|
||||
case "recurrence":
|
||||
return !this.task.recurrence;
|
||||
return !this.task.metadata.recurrence;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
|
@ -755,12 +828,18 @@ export class InlineEditor extends Component {
|
|||
this.isSaving = true;
|
||||
try {
|
||||
await this.options.onTaskUpdate(this.originalTask, this.task);
|
||||
this.originalTask = { ...this.task };
|
||||
this.originalTask = {
|
||||
...this.task,
|
||||
metadata: { ...this.task.metadata },
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save task:", error);
|
||||
// Revert changes on error
|
||||
this.task = { ...this.originalTask };
|
||||
this.task = {
|
||||
...this.originalTask,
|
||||
metadata: { ...this.originalTask.metadata },
|
||||
};
|
||||
return false;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
|
|
@ -768,9 +847,13 @@ export class InlineEditor extends Component {
|
|||
}
|
||||
|
||||
private hasTaskChanges(originalTask: Task, updatedTask: Task): boolean {
|
||||
// Compare key properties that can be edited
|
||||
const compareProps = [
|
||||
"content",
|
||||
// Compare content (top-level property)
|
||||
if (originalTask.content !== updatedTask.content) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare metadata properties
|
||||
const metadataProps = [
|
||||
"project",
|
||||
"tags",
|
||||
"context",
|
||||
|
|
@ -781,9 +864,13 @@ export class InlineEditor extends Component {
|
|||
"recurrence",
|
||||
];
|
||||
|
||||
for (const prop of compareProps) {
|
||||
const originalValue = (originalTask as any)[prop];
|
||||
const updatedValue = (updatedTask as any)[prop];
|
||||
for (const prop of metadataProps) {
|
||||
const originalValue = (
|
||||
originalTask.metadata as StandardTaskMetadata
|
||||
)[prop as keyof StandardTaskMetadata];
|
||||
const updatedValue = (updatedTask.metadata as StandardTaskMetadata)[
|
||||
prop as keyof StandardTaskMetadata
|
||||
];
|
||||
|
||||
// Handle array comparison for tags
|
||||
if (prop === "tags") {
|
||||
|
|
@ -834,7 +921,7 @@ export class InlineEditor extends Component {
|
|||
// Save the task and wait for completion
|
||||
const saveSuccess = await this.saveTask();
|
||||
|
||||
console.log("save success", saveSuccess)
|
||||
console.log("save success", saveSuccess);
|
||||
|
||||
if (!saveSuccess) {
|
||||
console.error("Failed to save task, not finishing edit");
|
||||
|
|
@ -930,7 +1017,10 @@ export class InlineEditor extends Component {
|
|||
|
||||
// Revert changes
|
||||
if (this.originalTask) {
|
||||
this.task = { ...this.originalTask };
|
||||
this.task = {
|
||||
...this.originalTask,
|
||||
metadata: { ...this.originalTask.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up editors first
|
||||
|
|
@ -962,16 +1052,20 @@ export class InlineEditor extends Component {
|
|||
// Restore the appropriate metadata display based on field type
|
||||
switch (fieldType) {
|
||||
case "project":
|
||||
if (this.task.project) {
|
||||
if (this.task.metadata.project) {
|
||||
targetEl.textContent =
|
||||
this.task.project.split("/").pop() || this.task.project;
|
||||
this.task.metadata.project.split("/").pop() ||
|
||||
this.task.metadata.project;
|
||||
targetEl.className = "task-project";
|
||||
}
|
||||
break;
|
||||
case "tags":
|
||||
if (this.task.tags && this.task.tags.length > 0) {
|
||||
if (
|
||||
this.task.metadata.tags &&
|
||||
this.task.metadata.tags.length > 0
|
||||
) {
|
||||
targetEl.className = "task-tags-container";
|
||||
this.task.tags
|
||||
this.task.metadata.tags
|
||||
.filter((tag) => !tag.startsWith("#project"))
|
||||
.forEach((tag) => {
|
||||
const tagEl = targetEl.createEl("span", {
|
||||
|
|
@ -982,15 +1076,17 @@ export class InlineEditor extends Component {
|
|||
}
|
||||
break;
|
||||
case "context":
|
||||
if (this.task.context) {
|
||||
targetEl.textContent = this.task.context;
|
||||
if (this.task.metadata.context) {
|
||||
targetEl.textContent = this.task.metadata.context;
|
||||
targetEl.className = "task-context";
|
||||
}
|
||||
break;
|
||||
case "dueDate":
|
||||
case "startDate":
|
||||
case "scheduledDate":
|
||||
const dateValue = this.task[fieldType as keyof Task] as number;
|
||||
const dateValue = (this.task.metadata as StandardTaskMetadata)[
|
||||
fieldType
|
||||
] as number;
|
||||
if (dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
targetEl.textContent = date.toLocaleDateString("en-US", {
|
||||
|
|
@ -1002,15 +1098,17 @@ export class InlineEditor extends Component {
|
|||
}
|
||||
break;
|
||||
case "recurrence":
|
||||
if (this.task.recurrence) {
|
||||
targetEl.textContent = this.task.recurrence;
|
||||
if (this.task.metadata.recurrence) {
|
||||
targetEl.textContent = this.task.metadata.recurrence;
|
||||
targetEl.className = "task-date task-recurrence";
|
||||
}
|
||||
break;
|
||||
case "priority":
|
||||
if (this.task.priority) {
|
||||
targetEl.textContent = "!".repeat(this.task.priority);
|
||||
targetEl.className = `task-priority priority-${this.task.priority}`;
|
||||
if (this.task.metadata.priority) {
|
||||
targetEl.textContent = "!".repeat(
|
||||
this.task.metadata.priority
|
||||
);
|
||||
targetEl.className = `task-priority priority-${this.task.metadata.priority}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
|
||||
// 为每个任务的项目建立索引
|
||||
this.allTasks.forEach((task) => {
|
||||
if (task.project) {
|
||||
if (!this.allProjectsMap.has(task.project)) {
|
||||
this.allProjectsMap.set(task.project, new Set());
|
||||
if (task.metadata.project) {
|
||||
if (!this.allProjectsMap.has(task.metadata.project)) {
|
||||
this.allProjectsMap.set(task.metadata.project, new Set());
|
||||
}
|
||||
this.allProjectsMap.get(task.project)?.add(task.id);
|
||||
this.allProjectsMap.get(task.metadata.project)?.add(task.id);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -147,15 +147,15 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
}
|
||||
|
||||
// 然后按优先级(高到低)
|
||||
const priorityA = a.priority || 0;
|
||||
const priorityB = b.priority || 0;
|
||||
const priorityA = a.metadata.priority || 0;
|
||||
const priorityB = b.metadata.priority || 0;
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityB - priorityA;
|
||||
}
|
||||
|
||||
// 然后按截止日期(早到晚)
|
||||
const dueDateA = a.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateA = a.metadata.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.metadata.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
return dueDateA - dueDateB;
|
||||
});
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
if (taskIndex !== -1) {
|
||||
const oldTask = this.allTasks[taskIndex];
|
||||
// 检查项目分配是否更改,这会影响侧边栏/过滤
|
||||
if (oldTask.project !== updatedTask.project) {
|
||||
if (oldTask.metadata.project !== updatedTask.metadata.project) {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
this.allTasks[taskIndex] = updatedTask;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export class TagViewComponent extends TwoColumnViewBase<string> {
|
|||
|
||||
// 为每个任务的标签建立索引
|
||||
this.allTasks.forEach((task) => {
|
||||
if (task.tags && task.tags.length > 0) {
|
||||
task.tags.forEach((tag) => {
|
||||
if (task.metadata.tags && task.metadata.tags.length > 0) {
|
||||
task.metadata.tags.forEach((tag) => {
|
||||
// 跳过非字符串类型的标签
|
||||
if (typeof tag !== "string") {
|
||||
return;
|
||||
|
|
@ -248,15 +248,15 @@ export class TagViewComponent extends TwoColumnViewBase<string> {
|
|||
}
|
||||
|
||||
// 然后按优先级(高到低)
|
||||
const priorityA = a.priority || 0;
|
||||
const priorityB = b.priority || 0;
|
||||
const priorityA = a.metadata.priority || 0;
|
||||
const priorityB = b.metadata.priority || 0;
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityB - priorityA;
|
||||
}
|
||||
|
||||
// 然后按截止日期(早到晚)
|
||||
const dueDateA = a.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateA = a.metadata.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
const dueDateB = b.metadata.dueDate || Number.MAX_SAFE_INTEGER;
|
||||
return dueDateA - dueDateB;
|
||||
});
|
||||
}
|
||||
|
|
@ -283,8 +283,8 @@ export class TagViewComponent extends TwoColumnViewBase<string> {
|
|||
const tagTaskMap = new Map<string, Task[]>();
|
||||
this.selectedItems.items.forEach((tag) => {
|
||||
const tasksForThisTagBranch = this.filteredTasks.filter((task) => {
|
||||
if (!task.tags) return false;
|
||||
return task.tags.some(
|
||||
if (!task.metadata.tags) return false;
|
||||
return task.metadata.tags.some(
|
||||
(taskTag) =>
|
||||
// 跳过非字符串类型的标签
|
||||
typeof taskTag === "string" &&
|
||||
|
|
@ -430,9 +430,10 @@ export class TagViewComponent extends TwoColumnViewBase<string> {
|
|||
const oldTask = this.allTasks[taskIndex];
|
||||
// 检查标签是否变化,需要重新构建/渲染
|
||||
const tagsChanged =
|
||||
!oldTask.tags ||
|
||||
!updatedTask.tags ||
|
||||
oldTask.tags.join(",") !== updatedTask.tags.join(",");
|
||||
!oldTask.metadata.tags ||
|
||||
!updatedTask.metadata.tags ||
|
||||
oldTask.metadata.tags.join(",") !==
|
||||
updatedTask.metadata.tags.join(",");
|
||||
|
||||
if (tagsChanged) {
|
||||
needsFullRefresh = true;
|
||||
|
|
@ -464,8 +465,8 @@ export class TagViewComponent extends TwoColumnViewBase<string> {
|
|||
this.tagSections.forEach((section) => {
|
||||
// 检查任务是否属于此分区的标签分支
|
||||
if (
|
||||
updatedTask.tags?.some(
|
||||
(taskTag) =>
|
||||
updatedTask.metadata.tags?.some(
|
||||
(taskTag: string) =>
|
||||
// 跳过非字符串类型的标签
|
||||
typeof taskTag === "string" &&
|
||||
(taskTag === section.tag ||
|
||||
|
|
|
|||
|
|
@ -156,14 +156,14 @@ export class TaskListRendererComponent extends Component {
|
|||
// Traverse upwards to find the highest ancestor that is NOT in this section,
|
||||
// or stop at the task itself if it has no parent or its parent IS in the section.
|
||||
while (
|
||||
currentTask.parent &&
|
||||
!sectionTaskIds.has(currentTask.parent)
|
||||
currentTask.metadata.parent &&
|
||||
!sectionTaskIds.has(currentTask.metadata.parent)
|
||||
) {
|
||||
const parentTask = allTasksMap.get(currentTask.parent);
|
||||
const parentTask = allTasksMap.get(currentTask.metadata.parent);
|
||||
if (!parentTask) {
|
||||
// Parent ID exists but task not found in map - data inconsistency? Stop ascending.
|
||||
console.warn(
|
||||
`Parent task ${currentTask.parent} not found in allTasksMap.`
|
||||
`Parent task ${currentTask.metadata.parent} not found in allTasksMap.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
|
@ -186,8 +186,8 @@ export class TaskListRendererComponent extends Component {
|
|||
rootTasksToRender.forEach((rootTask) => {
|
||||
// Find direct children of this root task using the *full* map
|
||||
const directChildren: Task[] = [];
|
||||
if (rootTask.children) {
|
||||
rootTask.children.forEach((childId) => {
|
||||
if (rootTask.metadata.children) {
|
||||
rootTask.metadata.children.forEach((childId: string) => {
|
||||
const childTask = allTasksMap.get(childId);
|
||||
if (childTask) {
|
||||
directChildren.push(childTask);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { t } from "../../translations/helper";
|
|||
import TaskProgressBarPlugin from "../../index";
|
||||
import { TwoColumnSpecificConfig } from "../../common/setting-definition";
|
||||
import "../../styles/property-view.css";
|
||||
import { getEffectiveProject } from "../../utils/taskUtil";
|
||||
|
||||
/**
|
||||
* A two-column view that displays task properties in the left column
|
||||
|
|
@ -105,24 +106,29 @@ export class TaskPropertyTwoColumnView extends TwoColumnViewBase<string> {
|
|||
private getPropertyValues(task: Task): any[] {
|
||||
switch (this.propertyKey) {
|
||||
case "tags":
|
||||
return task.tags || [];
|
||||
return task.metadata.tags || [];
|
||||
case "project":
|
||||
return task.project ? [task.project] : [];
|
||||
const effectiveProject = getEffectiveProject(task);
|
||||
return effectiveProject ? [effectiveProject] : [];
|
||||
case "priority":
|
||||
return task.priority !== undefined
|
||||
? [task.priority.toString()]
|
||||
return task.metadata.priority !== undefined
|
||||
? [task.metadata.priority.toString()]
|
||||
: [];
|
||||
case "context":
|
||||
return task.context ? [task.context] : [];
|
||||
return task.metadata.context ? [task.metadata.context] : [];
|
||||
case "status":
|
||||
return [task.status || ""];
|
||||
case "dueDate":
|
||||
return task.dueDate ? [this.formatDate(task.dueDate)] : [];
|
||||
return task.metadata.dueDate
|
||||
? [this.formatDate(task.metadata.dueDate)]
|
||||
: [];
|
||||
case "startDate":
|
||||
return task.startDate ? [this.formatDate(task.startDate)] : [];
|
||||
return task.metadata.startDate
|
||||
? [this.formatDate(task.metadata.startDate)]
|
||||
: [];
|
||||
case "scheduledDate":
|
||||
return task.scheduledDate
|
||||
? [this.formatDate(task.scheduledDate)]
|
||||
return task.metadata.scheduledDate
|
||||
? [this.formatDate(task.metadata.scheduledDate)]
|
||||
: [];
|
||||
case "filePath":
|
||||
// Extract just the filename without path and extension
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ export class CalendarComponent extends Component {
|
|||
cls: "mini-calendar-container",
|
||||
});
|
||||
|
||||
// Add hide-weekends class if weekend hiding is enabled
|
||||
if (!this.options.showWeekends) {
|
||||
this.containerEl.addClass("hide-weekends");
|
||||
}
|
||||
|
||||
// Create header with navigation
|
||||
this.createCalendarHeader();
|
||||
|
||||
|
|
@ -142,14 +147,19 @@ export class CalendarComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// Filter out weekend headers if showWeekends is false
|
||||
const filteredDayNames = this.options.showWeekends
|
||||
? sortedDayNames
|
||||
: sortedDayNames.filter(day => day !== "Sat" && day !== "Sun");
|
||||
|
||||
// Add day header cells
|
||||
sortedDayNames.forEach((day) => {
|
||||
filteredDayNames.forEach((day) => {
|
||||
const dayHeaderEl = this.calendarGridEl.createDiv({
|
||||
cls: "calendar-day-header",
|
||||
text: day,
|
||||
});
|
||||
|
||||
// Highlight weekend headers
|
||||
// Highlight weekend headers (only if they're shown)
|
||||
if (
|
||||
(day === "Sat" || day === "Sun") &&
|
||||
!this.options.showWeekends
|
||||
|
|
@ -263,6 +273,12 @@ export class CalendarComponent extends Component {
|
|||
isFuture: boolean,
|
||||
isThisMonth: boolean = false
|
||||
) {
|
||||
// Skip weekend days if showWeekends is false
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6; // Sunday or Saturday
|
||||
if (!this.options.showWeekends && isWeekend) {
|
||||
return; // Skip creating this day
|
||||
}
|
||||
|
||||
// Filter tasks for this day
|
||||
const dayTasks = this.getTasksForDate(date);
|
||||
|
||||
|
|
@ -302,7 +318,7 @@ export class CalendarComponent extends Component {
|
|||
|
||||
// Add class based on task priority
|
||||
const hasPriorityTasks = dayTasks.some(
|
||||
(task) => task.priority && task.priority >= 2
|
||||
(task) => task.metadata.priority && task.metadata.priority >= 2
|
||||
);
|
||||
if (hasPriorityTasks) {
|
||||
countEl.addClass("has-priority");
|
||||
|
|
@ -392,8 +408,8 @@ export class CalendarComponent extends Component {
|
|||
const endTimestamp = endOfDay.getTime();
|
||||
|
||||
return this.tasks.filter((task) => {
|
||||
if (task.dueDate) {
|
||||
const dueDate = new Date(task.dueDate);
|
||||
if (task.metadata.dueDate) {
|
||||
const dueDate = new Date(task.metadata.dueDate);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
return dueDate.getTime() === startTimestamp;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue