diff --git a/Tasknotes-Development-Guidelines.md b/Tasknotes-Development-Guidelines.md index ca302fc9..2632c2b8 100644 --- a/Tasknotes-Development-Guidelines.md +++ b/Tasknotes-Development-Guidelines.md @@ -234,7 +234,7 @@ Let's say you want to add a `complexity: 'simple' | 'medium' | 'hard'` property 4. **Update `TaskService.ts`**: * In `createTask`, handle the new property, possibly applying a default value. * In `updateTask`, ensure the new property can be updated. -5. **Update `BaseTaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity. +5. **Update `TaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity. 6. **Update `TaskCard.ts`**: In `createTaskCard` and `updateTaskCard`, add logic to display the new complexity information (e.g., an icon or text in the metadata line). 7. **Update `FilterService.ts` (Optional)**: If you want to filter by complexity frequently: * Consider whether an index is needed (probably not - compute on-demand) diff --git a/build-css.mjs b/build-css.mjs index 84a54044..b8b01304 100644 --- a/build-css.mjs +++ b/build-css.mjs @@ -13,7 +13,7 @@ const CSS_FILES = [ 'styles/note-card-bem.css', // NoteCard component with proper BEM scoping 'styles/filter-bar-bem.css', // FilterBar component with proper BEM scoping 'styles/modal-bem.css', // Modal components with proper BEM scoping - 'styles/minimalist-modal.css', // Minimalist modal components (Google Keep/Todoist style) + 'styles/task-modal.css', // Task modal components (Google Keep/Todoist style) 'styles/date-picker.css', // Enhanced date/time picker styling 'styles/task-selector-modal.css', // TaskSelectorModal component with proper BEM scoping 'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping @@ -66,221 +66,7 @@ const MAIN_CSS_TEMPLATE = `/* TaskNotes Plugin Styles */ `; -const REMAINING_STYLES = ` -/* Task Creation Modal */ -.task-creation-modal .input-with-counter { - position: relative; -} - -.task-creation-modal .character-counter { - position: absolute; - top: -1.2rem; - right: 0; - font-size: var(--cs-text-xs); - color: var(--text-muted); - font-weight: 500; -} - -.task-creation-modal .character-counter.warning { - color: var(--text-error); -} - -.task-creation-modal .autocomplete-container { - position: relative; -} - -.task-creation-modal .autocomplete-input { - width: 100%; - padding: 0.5rem; - border: 1px solid var(--background-modifier-border); - border-radius: var(--cs-radius-sm); - background: var(--background-primary); - color: var(--text-normal); - font-size: var(--cs-text-base); -} - -.task-creation-modal .autocomplete-suggestions { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-top: none; - border-radius: 0 0 var(--cs-radius-sm) var(--cs-radius-sm); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - z-index: 1000; - max-height: 200px; - overflow-y: auto; -} - -.task-creation-modal .autocomplete-suggestion { - padding: 0.5rem; - cursor: pointer; - border-bottom: 1px solid var(--background-modifier-border-hover); - font-size: var(--cs-text-sm); - color: var(--text-normal); -} - -.task-creation-modal .autocomplete-suggestion:hover, -.task-creation-modal .autocomplete-suggestion.selected { - background: var(--background-modifier-hover); -} - -.task-creation-modal .autocomplete-suggestion:last-child { - border-bottom: none; -} - -.task-creation-modal .form-group { - margin-bottom: 1rem; - position: relative; -} - -.task-creation-modal .form-group label { - display: block; - margin-bottom: 0.25rem; - font-weight: 500; - color: var(--text-normal); - font-size: var(--cs-text-sm); -} - -.task-creation-modal input, -.task-creation-modal select, -.task-creation-modal textarea { - width: 100%; - padding: 0.5rem; - border: 1px solid var(--background-modifier-border); - border-radius: var(--cs-radius-sm); - background: var(--background-primary); - color: var(--text-normal); - font-size: var(--cs-text-base); - transition: border-color var(--cs-transition-fast); -} - -.task-creation-modal input:focus, -.task-creation-modal select:focus, -.task-creation-modal textarea:focus { - outline: none; - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2); -} - -.task-creation-modal .button-container { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--background-modifier-border); -} - -.task-creation-modal .create-button { - background: var(--interactive-accent); - color: var(--text-on-accent); - border: none; - padding: 0.5rem 1rem; - border-radius: var(--cs-radius-sm); - font-weight: 500; - cursor: pointer; - transition: background-color var(--cs-transition-fast); -} - -.task-creation-modal .create-button:hover { - background: var(--interactive-accent-hover); -} - -.task-creation-modal .recurrence-helper-text { - font-size: var(--cs-text-xs); - color: var(--text-muted); - margin-top: 0.25rem; - font-style: italic; -} - -.task-creation-modal .days-container { - display: flex; - flex-direction: column; - gap: 0.25rem; - margin-top: 0.5rem; -} - -.task-creation-modal .day-checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-size: var(--cs-text-sm); -} - -.task-creation-modal .day-checkbox { - width: auto !important; - margin: 0; -} - -.task-creation-modal .time-estimate-container { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.task-creation-modal .time-estimate-container input { - width: 80px; - flex-shrink: 0; -} - -.task-creation-modal .time-unit-label { - font-size: var(--cs-text-sm); - color: var(--text-muted); -} - -/* Recurrence options styling */ -.task-creation-modal .recurrence-pattern-group { - border: 1px solid var(--background-modifier-border); - border-radius: var(--cs-radius-sm); - padding: var(--cs-spacing-sm); - background: var(--background-secondary); -} - -/* Days of week selection */ -.task-creation-modal .days-of-week-container { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: var(--cs-spacing-xs); - margin-top: var(--cs-spacing-sm); -} - -.task-creation-modal .day-button { - padding: var(--cs-spacing-xs) var(--cs-spacing-sm); - border: 1px solid var(--background-modifier-border); - border-radius: var(--cs-radius-sm); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer; - font-size: var(--cs-text-xs); - text-transform: uppercase; - letter-spacing: 0.05em; - transition: all var(--cs-transition-fast); - text-align: center; - min-height: var(--cs-touch-target); - display: flex; - align-items: center; - justify-content: center; -} - -.task-creation-modal .day-button:hover { - background: var(--background-modifier-hover); - border-color: var(--interactive-accent); -} - -.task-creation-modal .day-button.selected { - background: var(--interactive-accent); - color: var(--text-on-accent); - border-color: var(--interactive-accent); -} - -.task-creation-modal .day-button:focus { - outline: 2px solid var(--interactive-accent); - outline-offset: 2px; -}`; +const REMAINING_STYLES = ``; function buildCSS() { console.log('Building CSS...'); diff --git a/src/main.ts b/src/main.ts index 9caef7ee..1c07d4dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,9 +34,9 @@ import { AgendaView } from './views/AgendaView'; import { PomodoroView } from './views/PomodoroView'; import { PomodoroStatsView } from './views/PomodoroStatsView'; import { KanbanView } from './views/KanbanView'; -import { TaskCreationModal, TaskConversionOptions } from './modals/TaskCreationModal'; -import { MinimalistTaskCreationModal } from './modals/MinimalistTaskCreationModal'; -import { MinimalistTaskEditModal } from './modals/MinimalistTaskEditModal'; +import { TaskConversionOptions } from './types/taskConversion'; +import { TaskCreationModal } from './modals/TaskCreationModal'; +import { TaskEditModal } from './modals/TaskEditModal'; import { PomodoroService } from './services/PomodoroService'; import { formatTime, @@ -951,7 +951,7 @@ private injectCustomStyles(): void { } openTaskCreationModal(prePopulatedValues?: Partial) { - new MinimalistTaskCreationModal(this.app, this, { prePopulatedValues }).open(); + new TaskCreationModal(this.app, this, { prePopulatedValues }).open(); } @@ -1024,7 +1024,7 @@ private injectCustomStyles(): void { */ async openTaskEditModal(task: TaskInfo) { // With native cache, task data is always current - no need to refetch - new MinimalistTaskEditModal(this.app, this, { task }).open(); + new TaskEditModal(this.app, this, { task }).open(); } /** @@ -1114,7 +1114,7 @@ private injectCustomStyles(): void { prefilledDetails: details }; - // Convert the TaskConversionOptions to the new format expected by MinimalistTaskCreationModal + // Convert the TaskConversionOptions to the new format expected by TaskCreationModal const prePopulatedValues: Partial = {}; if (conversionOptions.parsedData) { @@ -1126,8 +1126,8 @@ private injectCustomStyles(): void { if (conversionOptions.parsedData.recurrence) prePopulatedValues.recurrence = conversionOptions.parsedData.recurrence; } - // Open MinimalistTaskCreationModal with pre-populated data and conversion callback - const modal = new MinimalistTaskCreationModal(this.app, this, { + // Open TaskCreationModal with pre-populated data and conversion callback + const modal = new TaskCreationModal(this.app, this, { prePopulatedValues, onTaskCreated: (taskInfo: TaskInfo) => { // Handle the task conversion completion - replace the original task line diff --git a/src/modals/BaseTaskModal.ts b/src/modals/BaseTaskModal.ts deleted file mode 100644 index 504e1ff7..00000000 --- a/src/modals/BaseTaskModal.ts +++ /dev/null @@ -1,1323 +0,0 @@ -import { App, Modal } from 'obsidian'; -import { RRule, Frequency, Weekday } from 'rrule'; -import TaskNotesPlugin from '../main'; -import { - validateDateInput, - getDatePart, - getTimePart, - combineDateAndTime -} from '../utils/dateUtils'; - -export abstract class BaseTaskModal extends Modal { - plugin: TaskNotesPlugin; - - // Form field properties - title = ''; - dueDate = ''; - scheduledDate = ''; - priority = 'normal'; - status = 'open'; - contexts = ''; - tags = ''; - timeEstimate = 0; - - // RRule-based recurrence properties - recurrenceRule = ''; // The actual rrule string - rruleFreq: Frequency | null = null; - rruleInterval = 1; - rruleByWeekday: Weekday[] = []; - rruleByMonthday: number[] = []; - rruleByMonth: number[] = []; - rruleBySetpos: number[] = []; // For nth weekday of month patterns - rruleUntil: Date | null = null; - rruleCount: number | null = null; - - // UI state properties - protected frequencyMode: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'NONE' = 'NONE'; - protected monthlyMode: 'day' | 'weekday' = 'day'; - protected endMode: 'never' | 'until' | 'count' = 'never'; - - // Time-related properties - protected dueTimeInput?: HTMLInputElement; - protected scheduledTimeInput?: HTMLInputElement; - - // Cached data - protected existingContexts: string[] = []; - protected existingTags: string[] = []; - - constructor(app: App, plugin: TaskNotesPlugin) { - super(app); - this.plugin = plugin; - } - - // Abstract methods for subclasses to implement - protected abstract initializeFormData(): Promise; - protected abstract createActionButtons(container: HTMLElement): void; - protected abstract handleSubmit(): Promise; - - // Get existing contexts from cache for instant autocomplete - async getExistingContexts(): Promise { - try { - return this.plugin.cacheManager.getAllContexts(); - } catch (error) { - console.error('Failed to get existing contexts:', error); - return this.existingContexts; // Return cached data as fallback - } - } - - // Get existing tags from cache for instant autocomplete - async getExistingTags(): Promise { - const allTags = this.plugin.cacheManager.getAllTags(); - // Filter out the default task tag - return allTags.filter(tag => tag !== this.plugin.settings.taskTag); - } - - // Helper methods for day name conversion - protected convertAbbreviationsToFullNames(abbreviations: string[]): string[] { - const dayMap: Record = { - 'mon': 'Monday', - 'tue': 'Tuesday', - 'wed': 'Wednesday', - 'thu': 'Thursday', - 'fri': 'Friday', - 'sat': 'Saturday', - 'sun': 'Sunday' - }; - - return abbreviations.map(abbr => dayMap[abbr]).filter(Boolean); - } - - protected convertFullNamesToAbbreviations(fullNames: string[]): string[] { - const dayMap: Record = { - 'Monday': 'mon', - 'Tuesday': 'tue', - 'Wednesday': 'wed', - 'Thursday': 'thu', - 'Friday': 'fri', - 'Saturday': 'sat', - 'Sunday': 'sun' - }; - - return fullNames.map(name => dayMap[name]).filter(Boolean); - } - - // RRule helper methods - protected parseRRuleString(rruleString: string): void { - if (!rruleString) { - this.resetRRuleProperties(); - return; - } - - try { - const rule = RRule.fromString(rruleString); - const options = rule.options; - - // Set frequency mode - switch (options.freq) { - case Frequency.DAILY: - this.frequencyMode = 'DAILY'; - break; - case Frequency.WEEKLY: - this.frequencyMode = 'WEEKLY'; - break; - case Frequency.MONTHLY: - this.frequencyMode = 'MONTHLY'; - break; - case Frequency.YEARLY: - this.frequencyMode = 'YEARLY'; - break; - default: - this.frequencyMode = 'NONE'; - return; - } - - // Set interval - this.rruleInterval = options.interval || 1; - - // Set weekdays for weekly recurrence - if (options.byweekday) { - this.rruleByWeekday = Array.isArray(options.byweekday) - ? (options.byweekday as number[]).map(wd => ({ weekday: wd })) as Weekday[] - : [{ weekday: options.byweekday as number } as Weekday]; - } - - // Set monthday for monthly/yearly recurrence - if (options.bymonthday) { - this.rruleByMonthday = Array.isArray(options.bymonthday) - ? options.bymonthday - : [options.bymonthday]; - } - - // Set month for yearly recurrence - if (options.bymonth) { - this.rruleByMonth = Array.isArray(options.bymonth) - ? options.bymonth - : [options.bymonth]; - } - - // Set setpos for nth weekday patterns - if (options.bysetpos) { - this.rruleBySetpos = Array.isArray(options.bysetpos) - ? options.bysetpos - : [options.bysetpos]; - } - - // Set end conditions - if (options.until) { - this.endMode = 'until'; - this.rruleUntil = options.until; - } else if (options.count) { - this.endMode = 'count'; - this.rruleCount = options.count; - } else { - this.endMode = 'never'; - } - - // Determine monthly mode - if (this.frequencyMode === 'MONTHLY') { - if (this.rruleByWeekday.length > 0 && this.rruleBySetpos.length > 0) { - this.monthlyMode = 'weekday'; - } else { - this.monthlyMode = 'day'; - } - } - - } catch (error) { - console.error('Error parsing rrule string:', error); - this.resetRRuleProperties(); - } - } - - protected generateRRuleString(): string { - if (this.frequencyMode === 'NONE') { - return ''; - } - - try { - const options: Partial = { - freq: this.getFrequencyConstant(), - interval: this.rruleInterval || 1 - }; - - // Add frequency-specific options - switch (this.frequencyMode) { - case 'WEEKLY': - if (this.rruleByWeekday.length > 0) { - options.byweekday = this.rruleByWeekday; - } - break; - case 'MONTHLY': - if (this.monthlyMode === 'day' && this.rruleByMonthday.length > 0) { - options.bymonthday = this.rruleByMonthday; - } else if (this.monthlyMode === 'weekday' && this.rruleByWeekday.length > 0 && this.rruleBySetpos.length > 0) { - options.byweekday = this.rruleByWeekday; - options.bysetpos = this.rruleBySetpos; - } - break; - case 'YEARLY': - if (this.rruleByMonth.length > 0) { - options.bymonth = this.rruleByMonth; - } - if (this.rruleByMonthday.length > 0) { - options.bymonthday = this.rruleByMonthday; - } - break; - } - - // Add end conditions - if (this.endMode === 'until' && this.rruleUntil) { - options.until = this.rruleUntil; - } else if (this.endMode === 'count' && this.rruleCount) { - options.count = this.rruleCount; - } - - const rule = new RRule(options); - return rule.toString(); - } catch (error) { - console.error('Error generating rrule string:', error); - return ''; - } - } - - private getFrequencyConstant(): Frequency { - switch (this.frequencyMode) { - case 'DAILY': return Frequency.DAILY; - case 'WEEKLY': return Frequency.WEEKLY; - case 'MONTHLY': return Frequency.MONTHLY; - case 'YEARLY': return Frequency.YEARLY; - default: return Frequency.DAILY; - } - } - - private resetRRuleProperties(): void { - this.frequencyMode = 'NONE'; - this.rruleInterval = 1; - this.rruleByWeekday = []; - this.rruleByMonthday = []; - this.rruleByMonth = []; - this.rruleBySetpos = []; - this.rruleUntil = null; - this.rruleCount = null; - this.monthlyMode = 'day'; - this.endMode = 'never'; - } - - protected getRRuleHumanText(): string { - if (!this.recurrenceRule) { - return 'No recurrence'; - } - - try { - const rule = RRule.fromString(this.recurrenceRule); - return rule.toText(); - } catch (error) { - console.error('Error generating human text for rrule:', error); - return 'Invalid recurrence rule'; - } - } - - protected createFormGroup(container: HTMLElement, label: string, inputCallback: (container: HTMLElement) => void): HTMLElement { - const formGroup = container.createDiv({ cls: 'modal-form__group' }); - const labelId = `form-label-${Math.random().toString(36).substring(2, 11)}`; - formGroup.createEl('label', { - text: label, - cls: 'modal-form__label', - attr: { 'id': labelId } - }); - const inputContainer = formGroup.createDiv({ cls: 'modal-form__input-container' }); - inputContainer.setAttribute('aria-labelledby', labelId); - inputCallback(inputContainer); - return formGroup; - } - - protected async createAutocompleteInput( - container: HTMLElement, - fieldName: string, - getSuggestionsFn: () => Promise | string[], - onChangeFn: (value: string) => void - ): Promise { - const inputId = `autocomplete-${fieldName}-${Math.random().toString(36).substring(2, 11)}`; - const listboxId = `listbox-${fieldName}-${Math.random().toString(36).substring(2, 11)}`; - - const input = container.createEl('input', { - type: 'text', - cls: 'modal-form__input', - attr: { - 'id': inputId, - 'aria-label': `Enter ${fieldName} (comma-separated)`, - 'aria-autocomplete': 'list', - 'aria-expanded': 'false', - 'aria-haspopup': 'listbox', - 'role': 'combobox' - } - }); - - input.value = (this as any)[fieldName] || ''; - - input.addEventListener('input', (e) => { - const value = (e.target as HTMLInputElement).value; - (this as any)[fieldName] = value; - onChangeFn(value); - }); - - input.addEventListener('focus', async () => { - let suggestions = await getSuggestionsFn(); - - // If suggestions are empty, try to fetch fresh data - if (!suggestions || suggestions.length === 0) { - if (fieldName === 'contexts') { - suggestions = await this.getExistingContexts(); - this.existingContexts = suggestions; - } else if (fieldName === 'tags') { - suggestions = await this.getExistingTags(); - this.existingTags = suggestions; - } - } - - this.showSuggestions(container, suggestions, input, onChangeFn, listboxId); - }); - - input.addEventListener('blur', () => { - setTimeout(() => this.hideSuggestions(container), 200); - }); - - input.addEventListener('keydown', (e) => { - const suggestionsList = container.querySelector('.modal-form__suggestions') as HTMLElement; - if (!suggestionsList) return; - - const suggestions = suggestionsList.querySelectorAll('.modal-form__suggestion'); - let selectedIndex = Array.from(suggestions).findIndex(el => el.classList.contains('modal-form__suggestion--selected')); - - if (e.key === 'ArrowDown') { - e.preventDefault(); - selectedIndex = (selectedIndex + 1) % suggestions.length; - this.updateSelectedSuggestion(suggestions, selectedIndex); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1; - this.updateSelectedSuggestion(suggestions, selectedIndex); - } else if (e.key === 'Enter' && selectedIndex >= 0) { - e.preventDefault(); - const selectedSuggestion = suggestions[selectedIndex].textContent; - if (selectedSuggestion) { - this.applySuggestion(input, selectedSuggestion, onChangeFn); - } - } else if (e.key === 'Escape') { - this.hideSuggestions(container); - } - }); - } - - protected showSuggestions( - container: HTMLElement, - suggestions: string[], - input: HTMLInputElement, - onChangeFn: (value: string) => void, - listboxId?: string - ): void { - this.hideSuggestions(container); - - if (suggestions.length === 0) { - input.setAttribute('aria-expanded', 'false'); - return; - } - - const suggestionsList = container.createDiv({ - cls: 'modal-form__suggestions', - attr: { - 'role': 'listbox', - 'id': listboxId || `suggestions-${Math.random().toString(36).substring(2, 11)}`, - 'aria-label': 'Suggestions' - } - }); - - input.setAttribute('aria-expanded', 'true'); - input.setAttribute('aria-controls', suggestionsList.id); - - // Get the current partial value being typed (after the last comma) - const inputValue = input.value; - const lastCommaIndex = inputValue.lastIndexOf(','); - const currentPartial = lastCommaIndex >= 0 - ? inputValue.substring(lastCommaIndex + 1).trim().toLowerCase() - : inputValue.toLowerCase(); - - const filteredSuggestions = suggestions.filter(suggestion => { - const suggestionLower = suggestion.toLowerCase(); - // Show suggestions that match the current partial input - // or show all if input is empty/just spaces - return currentPartial === '' || suggestionLower.includes(currentPartial); - }); - - filteredSuggestions.slice(0, 10).forEach((suggestion, index) => { - const suggestionItem = suggestionsList.createDiv({ - cls: 'modal-form__suggestion', - text: suggestion, - attr: { - 'role': 'option', - 'id': `suggestion-${index}`, - 'aria-selected': index === 0 ? 'true' : 'false', - 'tabindex': '-1' - } - }); - - if (index === 0) { - suggestionItem.addClass('modal-form__suggestion--selected'); - input.setAttribute('aria-activedescendant', `suggestion-${index}`); - } - - suggestionItem.addEventListener('click', () => { - this.applySuggestion(input, suggestion, onChangeFn); - }); - }); - } - - protected applySuggestion(input: HTMLInputElement, suggestion: string, onChangeFn: (value: string) => void): void { - const currentValue = input.value; - const lastCommaIndex = currentValue.lastIndexOf(','); - - let newValue: string; - if (lastCommaIndex >= 0) { - newValue = currentValue.substring(0, lastCommaIndex + 1) + ' ' + suggestion; - } else { - newValue = suggestion; - } - - input.value = newValue; - onChangeFn(newValue); - this.hideSuggestions(input.parentElement!); - input.focus(); - } - - protected updateSelectedSuggestion(suggestions: NodeListOf, selectedIndex: number): void { - suggestions.forEach((suggestion, index) => { - if (index === selectedIndex) { - suggestion.classList.add('modal-form__suggestion--selected'); - suggestion.setAttribute('aria-selected', 'true'); - // Update aria-activedescendant on the input - const input = suggestion.closest('.modal-form__input-container')?.querySelector('input'); - if (input) { - input.setAttribute('aria-activedescendant', suggestion.id); - } - } else { - suggestion.classList.remove('modal-form__suggestion--selected'); - suggestion.setAttribute('aria-selected', 'false'); - } - }); - } - - protected hideSuggestions(container: HTMLElement): void { - const existingSuggestions = container.querySelector('.modal-form__suggestions'); - if (existingSuggestions) { - existingSuggestions.remove(); - // Clean up aria attributes on the input - const input = container.querySelector('input'); - if (input) { - input.setAttribute('aria-expanded', 'false'); - input.removeAttribute('aria-activedescendant'); - input.removeAttribute('aria-controls'); - } - } - } - - protected createTitleInputWithCounter(container: HTMLElement, maxLength: number): void { - const inputId = `title-input-${Math.random().toString(36).substring(2, 11)}`; - const titleInput = container.createEl('input', { - type: 'text', - cls: 'modal-form__input modal-form__input--title', - attr: { - maxlength: maxLength.toString(), - 'id': inputId, - 'aria-label': 'Task title', - 'aria-describedby': `${inputId}-counter` - } - }); - - titleInput.value = this.title; - - const charCounter = container.createDiv({ - cls: 'modal-form__char-counter', - attr: { - 'id': `${inputId}-counter`, - 'aria-live': 'polite', - 'aria-label': 'Character count' - } - }); - this.updateCharCounter(charCounter, this.title.length, maxLength); - - titleInput.addEventListener('input', (e) => { - const value = (e.target as HTMLInputElement).value; - this.title = value; - this.updateCharCounter(charCounter, value.length, maxLength); - }); - } - - protected updateCharCounter(counter: HTMLElement, currentLength: number, maxLength: number): void { - counter.textContent = `${currentLength}/${maxLength}`; - - if (currentLength > maxLength * 0.9) { - counter.addClass('modal-form__char-counter--warning'); - } else { - counter.removeClass('modal-form__char-counter--warning'); - } - } - - protected createPriorityDropdown(container: HTMLElement): void { - const selectId = `priority-select-${Math.random().toString(36).substring(2, 11)}`; - const select = container.createEl('select', { - cls: 'modal-form__select', - attr: { - 'id': selectId, - 'aria-label': 'Task priority' - } - }); - - this.plugin.priorityManager.getPrioritiesByWeight().forEach(priorityConfig => { - const option = select.createEl('option', { - value: priorityConfig.value, - text: priorityConfig.label - }); - - if (priorityConfig.value === this.priority) { - option.selected = true; - } - }); - - select.addEventListener('change', (e) => { - this.priority = (e.target as HTMLSelectElement).value; - }); - } - - protected createStatusDropdown(container: HTMLElement): void { - const selectId = `status-select-${Math.random().toString(36).substring(2, 11)}`; - const select = container.createEl('select', { - cls: 'modal-form__select', - attr: { - 'id': selectId, - 'aria-label': 'Task status' - } - }); - - this.plugin.statusManager.getAllStatuses().forEach(statusConfig => { - const option = select.createEl('option', { - value: statusConfig.value, - text: statusConfig.label - }); - - if (statusConfig.value === this.status) { - option.selected = true; - } - }); - - select.addEventListener('change', (e) => { - this.status = (e.target as HTMLSelectElement).value; - }); - } - - protected createDueDateInput(container: HTMLElement): void { - const inputContainer = container.createDiv({ cls: 'modal-form__datetime-container' }); - - // Date input - const dateInput = inputContainer.createEl('input', { - type: 'date', - cls: 'modal-form__input modal-form__input--date', - attr: { - 'aria-label': 'Due date', - 'placeholder': 'YYYY-MM-DD' - } - }); - - // Extract and set date part - const datePart = getDatePart(this.dueDate); - if (datePart && validateDateInput(datePart)) { - dateInput.value = datePart; - } - - // Time input (always visible but optional) - this.dueTimeInput = inputContainer.createEl('input', { - type: 'time', - cls: 'modal-form__input modal-form__input--time', - attr: { - 'aria-label': 'Due time (optional)', - 'placeholder': 'HH:MM' - } - }); - - // Extract and set time part - const timePart = getTimePart(this.dueDate); - if (timePart) { - this.dueTimeInput.value = timePart; - } - - // Event listeners - dateInput.addEventListener('change', (e) => { - const dateValue = (e.target as HTMLInputElement).value; - this.updateDueDateValue(dateValue, this.dueTimeInput?.value || ''); - }); - - this.dueTimeInput.addEventListener('change', (e) => { - const timeValue = (e.target as HTMLInputElement).value; - this.updateDueDateValue(dateInput.value, timeValue); - }); - - // Add help text for due date - this.createHelpText(container, - 'Task deadline for reference. This is separate from recurrence end dates - it appears in task info but doesn\'t control when recurring instances stop.'); - } - - private updateDueDateValue(dateValue: string, timeValue: string): void { - if (!dateValue) { - this.dueDate = ''; - return; - } - - if (timeValue && timeValue.trim()) { - this.dueDate = combineDateAndTime(dateValue, timeValue); - } else { - this.dueDate = dateValue; - } - } - - protected createScheduledDateInput(container: HTMLElement): void { - const inputContainer = container.createDiv({ cls: 'modal-form__datetime-container' }); - - // Date input - const dateInput = inputContainer.createEl('input', { - type: 'date', - cls: 'modal-form__input modal-form__input--date', - attr: { - 'aria-label': 'Scheduled date', - 'placeholder': 'YYYY-MM-DD' - } - }); - - // Extract and set date part - const datePart = getDatePart(this.scheduledDate); - if (datePart && validateDateInput(datePart)) { - dateInput.value = datePart; - } - - // Time input (always visible but optional) - this.scheduledTimeInput = inputContainer.createEl('input', { - type: 'time', - cls: 'modal-form__input modal-form__input--time', - attr: { - 'aria-label': 'Scheduled time (optional)', - 'placeholder': 'HH:MM' - } - }); - - // Extract and set time part - const timePart = getTimePart(this.scheduledDate); - if (timePart) { - this.scheduledTimeInput.value = timePart; - } - - // Event listeners - dateInput.addEventListener('change', (e) => { - const dateValue = (e.target as HTMLInputElement).value; - this.updateScheduledDateValue(dateValue, this.scheduledTimeInput?.value || ''); - }); - - this.scheduledTimeInput.addEventListener('change', (e) => { - const timeValue = (e.target as HTMLInputElement).value; - this.updateScheduledDateValue(dateInput.value, timeValue); - }); - - // Add help text for scheduled date - this.createHelpText(container, - 'When you plan to work on this task.'); - } - - private updateScheduledDateValue(dateValue: string, timeValue: string): void { - if (!dateValue) { - this.scheduledDate = ''; - return; - } - - if (timeValue && timeValue.trim()) { - this.scheduledDate = combineDateAndTime(dateValue, timeValue); - } else { - this.scheduledDate = dateValue; - } - } - - protected createTimeEstimateInput(container: HTMLElement): void { - const inputContainer = container.createDiv({ cls: 'modal-form__time-estimate' }); - const inputId = `time-estimate-input-${Math.random().toString(36).substring(2, 11)}`; - - const input = inputContainer.createEl('input', { - type: 'number', - cls: 'modal-form__input modal-form__input--number', - attr: { - min: '0', - step: '5', - 'id': inputId, - 'aria-label': 'Time estimate in minutes', - 'aria-describedby': `${inputId}-label` - } - }); - - // Ensure timeEstimate is properly initialized and preserved - const currentTimeEstimate = this.timeEstimate || 0; - input.value = currentTimeEstimate.toString(); - - const label = inputContainer.createSpan({ - cls: 'modal-form__time-label', - attr: { 'id': `${inputId}-label` } - }); - this.updateTimeLabel(label, this.timeEstimate); - - input.addEventListener('input', (e) => { - const value = parseInt((e.target as HTMLInputElement).value) || 0; - this.timeEstimate = value; - this.updateTimeLabel(label, value); - }); - } - - protected updateTimeLabel(label: HTMLElement, value: number): void { - if (value === 0) { - label.textContent = 'No estimate'; - } else if (value < 60) { - label.textContent = `${value} minute${value === 1 ? '' : 's'}`; - } else { - const hours = Math.floor(value / 60); - const minutes = value % 60; - let text = `${hours} hour${hours === 1 ? '' : 's'}`; - if (minutes > 0) { - text += ` ${minutes} minute${minutes === 1 ? '' : 's'}`; - } - label.textContent = text; - } - } - - protected createHelpText(container: HTMLElement, text: string): void { - container.createDiv({ - cls: 'modal-form__help-text', - text: text - }); - } - - protected createRRuleBuilder(container: HTMLElement): void { - // Frequency dropdown - this.createFrequencyDropdown(container); - - // Interval input - this.createIntervalInput(container); - - // Options container for frequency-specific settings - const optionsContainer = container.createDiv({ cls: 'modal-form__rrule-options' }); - this.updateRRuleFrequencyOptions(optionsContainer); - - // End condition options - this.createEndConditionOptions(container); - - // Human-readable summary - this.createRRuleSummary(container); - - // Add help text for recurrence - this.createHelpText(container, - 'Create recurring instances of this task. Note: Recurring tasks will only appear in calendar views when they have a scheduled date. The scheduled date determines when the recurrence pattern starts and what time the recurring instances appear.'); - } - - private createFrequencyDropdown(container: HTMLElement): void { - const selectId = `frequency-select-${Math.random().toString(36).substring(2, 11)}`; - const select = container.createEl('select', { - cls: 'modal-form__select', - attr: { - 'id': selectId, - 'aria-label': 'Recurrence frequency' - } - }); - - const options = [ - { value: 'NONE', text: 'No recurrence' }, - { value: 'DAILY', text: 'Daily' }, - { value: 'WEEKLY', text: 'Weekly' }, - { value: 'MONTHLY', text: 'Monthly' }, - { value: 'YEARLY', text: 'Yearly' } - ]; - - options.forEach(option => { - const optionEl = select.createEl('option', { - value: option.value, - text: option.text - }); - - if (option.value === this.frequencyMode) { - optionEl.selected = true; - } - }); - - select.addEventListener('change', (e) => { - this.frequencyMode = (e.target as HTMLSelectElement).value as any; - - // Update interval container visibility - look in the same container, not parent - const intervalContainer = container.querySelector('.modal-form__interval-container') as HTMLElement; - if (intervalContainer) { - if (this.frequencyMode === 'NONE') { - intervalContainer.style.display = 'none'; - } else { - intervalContainer.style.display = 'block'; - // Update the interval input value and unit when frequency changes - const intervalInput = intervalContainer.querySelector('.modal-form__input--interval') as HTMLInputElement; - const unitSpan = intervalContainer.querySelector('.modal-form__interval-unit') as HTMLElement; - if (intervalInput) { - intervalInput.value = this.rruleInterval.toString(); - } - if (unitSpan) { - this.updateIntervalUnit(unitSpan); - } - } - } - - // Update end condition container visibility - look in the same container, not parent - const endContainer = container.querySelector('.modal-form__end-condition') as HTMLElement; - if (endContainer) { - if (this.frequencyMode === 'NONE') { - endContainer.style.display = 'none'; - } else { - endContainer.style.display = 'block'; - } - } - - const optionsContainer = container.querySelector('.modal-form__rrule-options') as HTMLElement; - if (optionsContainer) { - this.updateRRuleFrequencyOptions(optionsContainer); - } - this.updateRRuleString(); - }); - } - - private createIntervalInput(container: HTMLElement): void { - // Create interval container and always create the input elements - const intervalContainer = container.createDiv({ cls: 'modal-form__interval-container' }); - - // Always create the input elements, even if initially hidden - intervalContainer.createSpan({ text: 'Every ', cls: 'modal-form__interval-label' }); - - const input = intervalContainer.createEl('input', { - type: 'number', - cls: 'modal-form__input modal-form__input--interval', - attr: { - min: '1', - max: '999', - value: this.rruleInterval.toString() - } - }); - - const unitSpan = intervalContainer.createSpan({ cls: 'modal-form__interval-unit' }); - this.updateIntervalUnit(unitSpan); - - input.addEventListener('input', (e) => { - const value = parseInt((e.target as HTMLInputElement).value) || 1; - this.rruleInterval = Math.max(1, Math.min(999, value)); - this.updateIntervalUnit(unitSpan); - this.updateRRuleString(); - }); - - // Hide initially if no frequency is selected - if (this.frequencyMode === 'NONE') { - intervalContainer.style.display = 'none'; - } - } - - protected updateIntervalUnit(unitSpan: HTMLElement): void { - const interval = this.rruleInterval; - const isPlural = interval !== 1; - - switch (this.frequencyMode) { - case 'DAILY': - unitSpan.textContent = isPlural ? 'days' : 'day'; - break; - case 'WEEKLY': - unitSpan.textContent = isPlural ? 'weeks' : 'week'; - break; - case 'MONTHLY': - unitSpan.textContent = isPlural ? 'months' : 'month'; - break; - case 'YEARLY': - unitSpan.textContent = isPlural ? 'years' : 'year'; - break; - default: - unitSpan.textContent = ''; - } - } - - protected updateRRuleFrequencyOptions(container: HTMLElement): void { - container.empty(); - - switch (this.frequencyMode) { - case 'WEEKLY': - this.createWeeklyOptions(container); - break; - case 'MONTHLY': - this.createMonthlyOptions(container); - break; - case 'YEARLY': - this.createYearlyOptions(container); - break; - } - } - - private createWeeklyOptions(container: HTMLElement): void { - container.createEl('label', { - text: 'On these days:', - cls: 'modal-form__rrule-label' - }); - - const daysContainer = container.createDiv({ cls: 'modal-form__days-grid' }); - - const days = [ - { name: 'Monday', weekday: RRule.MO }, - { name: 'Tuesday', weekday: RRule.TU }, - { name: 'Wednesday', weekday: RRule.WE }, - { name: 'Thursday', weekday: RRule.TH }, - { name: 'Friday', weekday: RRule.FR }, - { name: 'Saturday', weekday: RRule.SA }, - { name: 'Sunday', weekday: RRule.SU } - ]; - - days.forEach(day => { - const dayContainer = daysContainer.createDiv({ cls: 'modal-form__day-checkbox' }); - const checkboxId = `day-${day.name.toLowerCase()}-${Math.random().toString(36).substring(2, 11)}`; - - const checkbox = dayContainer.createEl('input', { - type: 'checkbox', - cls: 'modal-form__day-input', - attr: { - 'id': checkboxId, - 'aria-label': `Include ${day.name} in weekly recurrence` - } - }); - - checkbox.checked = this.rruleByWeekday.some(wd => wd.weekday === day.weekday.weekday); - - dayContainer.createEl('label', { - text: day.name.substring(0, 3), - cls: 'modal-form__day-label', - attr: { 'for': checkboxId } - }); - - checkbox.addEventListener('change', (e) => { - if ((e.target as HTMLInputElement).checked) { - if (!this.rruleByWeekday.some(wd => wd.weekday === day.weekday.weekday)) { - this.rruleByWeekday.push(day.weekday); - } - } else { - this.rruleByWeekday = this.rruleByWeekday.filter(wd => wd.weekday !== day.weekday.weekday); - } - this.updateRRuleString(); - }); - }); - } - - private createMonthlyOptions(container: HTMLElement): void { - const modeContainer = container.createDiv({ cls: 'modal-form__monthly-mode' }); - - // Radio buttons for monthly mode - const dayModeId = `monthly-day-${Math.random().toString(36).substring(2, 11)}`; - const weekdayModeId = `monthly-weekday-${Math.random().toString(36).substring(2, 11)}`; - - const dayModeContainer = modeContainer.createDiv({ cls: 'modal-form__radio-option' }); - const dayModeRadio = dayModeContainer.createEl('input', { - type: 'radio', - value: 'day', - attr: { 'id': dayModeId, 'name': 'monthly-mode' } - }); - dayModeRadio.checked = this.monthlyMode === 'day'; - - dayModeContainer.createEl('label', { - text: 'On day ', - attr: { 'for': dayModeId } - }); - - const dayInput = dayModeContainer.createEl('input', { - type: 'number', - cls: 'modal-form__input modal-form__input--day', - attr: { - min: '1', - max: '31', - value: this.rruleByMonthday.length > 0 ? this.rruleByMonthday[0].toString() : '1' - } - }); - - const weekdayModeContainer = modeContainer.createDiv({ cls: 'modal-form__radio-option' }); - const weekdayModeRadio = weekdayModeContainer.createEl('input', { - type: 'radio', - value: 'weekday', - attr: { 'id': weekdayModeId, 'name': 'monthly-mode' } - }); - weekdayModeRadio.checked = this.monthlyMode === 'weekday'; - - weekdayModeContainer.createEl('label', { - text: 'On the ', - attr: { 'for': weekdayModeId } - }); - - const positionSelect = weekdayModeContainer.createEl('select', { cls: 'modal-form__select modal-form__select--position' }); - const positions = [ - { value: '1', text: 'first' }, - { value: '2', text: 'second' }, - { value: '3', text: 'third' }, - { value: '4', text: 'fourth' }, - { value: '-1', text: 'last' } - ]; - - positions.forEach(pos => { - const option = positionSelect.createEl('option', { - value: pos.value, - text: pos.text - }); - if (this.rruleBySetpos.length > 0 && this.rruleBySetpos[0].toString() === pos.value) { - option.selected = true; - } - }); - - const weekdaySelect = weekdayModeContainer.createEl('select', { cls: 'modal-form__select modal-form__select--weekday' }); - const weekdays = [ - { value: RRule.MO.weekday.toString(), text: 'Monday' }, - { value: RRule.TU.weekday.toString(), text: 'Tuesday' }, - { value: RRule.WE.weekday.toString(), text: 'Wednesday' }, - { value: RRule.TH.weekday.toString(), text: 'Thursday' }, - { value: RRule.FR.weekday.toString(), text: 'Friday' }, - { value: RRule.SA.weekday.toString(), text: 'Saturday' }, - { value: RRule.SU.weekday.toString(), text: 'Sunday' } - ]; - - weekdays.forEach(wd => { - const option = weekdaySelect.createEl('option', { - value: wd.value, - text: wd.text - }); - if (this.rruleByWeekday.length > 0 && this.rruleByWeekday[0].weekday.toString() === wd.value) { - option.selected = true; - } - }); - - // Event listeners - dayModeRadio.addEventListener('change', () => { - if (dayModeRadio.checked) { - this.monthlyMode = 'day'; - this.rruleByMonthday = [parseInt(dayInput.value) || 1]; - this.rruleByWeekday = []; - this.rruleBySetpos = []; - this.updateRRuleString(); - } - }); - - weekdayModeRadio.addEventListener('change', () => { - if (weekdayModeRadio.checked) { - this.monthlyMode = 'weekday'; - this.rruleByMonthday = []; - this.updateMonthlyWeekdayRule(positionSelect.value, weekdaySelect.value); - this.updateRRuleString(); - } - }); - - dayInput.addEventListener('change', (e) => { - if (this.monthlyMode === 'day') { - const value = parseInt((e.target as HTMLInputElement).value) || 1; - this.rruleByMonthday = [Math.max(1, Math.min(31, value))]; - this.updateRRuleString(); - } - }); - - positionSelect.addEventListener('change', (e) => { - if (this.monthlyMode === 'weekday') { - this.updateMonthlyWeekdayRule((e.target as HTMLSelectElement).value, weekdaySelect.value); - this.updateRRuleString(); - } - }); - - weekdaySelect.addEventListener('change', (e) => { - if (this.monthlyMode === 'weekday') { - this.updateMonthlyWeekdayRule(positionSelect.value, (e.target as HTMLSelectElement).value); - this.updateRRuleString(); - } - }); - } - - private updateMonthlyWeekdayRule(position: string, weekday: string): void { - this.rruleBySetpos = [parseInt(position)]; - const weekdayNum = parseInt(weekday); - - // Map weekday numbers to RRule weekday objects - const weekdayMap: Record = { - 0: RRule.MO, - 1: RRule.TU, - 2: RRule.WE, - 3: RRule.TH, - 4: RRule.FR, - 5: RRule.SA, - 6: RRule.SU - }; - - if (weekdayMap[weekdayNum]) { - this.rruleByWeekday = [weekdayMap[weekdayNum]]; - } - } - - private createYearlyOptions(container: HTMLElement): void { - const yearlyContainer = container.createDiv({ cls: 'modal-form__yearly-options' }); - - yearlyContainer.createSpan({ text: 'In ', cls: 'modal-form__yearly-label' }); - - const monthSelect = yearlyContainer.createEl('select', { cls: 'modal-form__select modal-form__select--month' }); - const months = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December' - ]; - - months.forEach((month, index) => { - const option = monthSelect.createEl('option', { - value: (index + 1).toString(), - text: month - }); - if (this.rruleByMonth.length > 0 && this.rruleByMonth[0] === index + 1) { - option.selected = true; - } - }); - - yearlyContainer.createSpan({ text: ' on day ', cls: 'modal-form__yearly-label' }); - - const dayInput = yearlyContainer.createEl('input', { - type: 'number', - cls: 'modal-form__input modal-form__input--day', - attr: { - min: '1', - max: '31', - value: this.rruleByMonthday.length > 0 ? this.rruleByMonthday[0].toString() : '1' - } - }); - - monthSelect.addEventListener('change', (e) => { - const value = parseInt((e.target as HTMLSelectElement).value); - this.rruleByMonth = [value]; - this.updateRRuleString(); - }); - - dayInput.addEventListener('change', (e) => { - const value = parseInt((e.target as HTMLInputElement).value) || 1; - this.rruleByMonthday = [Math.max(1, Math.min(31, value))]; - this.updateRRuleString(); - }); - } - - private createEndConditionOptions(container: HTMLElement): void { - const endContainer = container.createDiv({ cls: 'modal-form__end-condition' }); - - if (this.frequencyMode === 'NONE') { - endContainer.style.display = 'none'; - } - - endContainer.createEl('label', { - text: 'Ends:', - cls: 'modal-form__rrule-label' - }); - - const endOptionsContainer = endContainer.createDiv({ cls: 'modal-form__end-options' }); - - // Never radio button - const neverContainer = endOptionsContainer.createDiv({ cls: 'modal-form__radio-option' }); - const neverId = `end-never-${Math.random().toString(36).substring(2, 11)}`; - const neverRadio = neverContainer.createEl('input', { - type: 'radio', - value: 'never', - attr: { 'id': neverId, 'name': 'end-mode' } - }); - neverRadio.checked = this.endMode === 'never'; - neverContainer.createEl('label', { - text: 'Never', - attr: { 'for': neverId } - }); - - // Until date radio button - const untilContainer = endOptionsContainer.createDiv({ cls: 'modal-form__radio-option' }); - const untilId = `end-until-${Math.random().toString(36).substring(2, 11)}`; - const untilRadio = untilContainer.createEl('input', { - type: 'radio', - value: 'until', - attr: { 'id': untilId, 'name': 'end-mode' } - }); - untilRadio.checked = this.endMode === 'until'; - untilContainer.createEl('label', { - text: 'On specific date: ', - attr: { 'for': untilId } - }); - - const untilDateInput = untilContainer.createEl('input', { - type: 'date', - cls: 'modal-form__input modal-form__input--date', - attr: { - value: this.rruleUntil ? this.rruleUntil.toISOString().split('T')[0] : '' - } - }); - - // After count radio button - const countContainer = endOptionsContainer.createDiv({ cls: 'modal-form__radio-option' }); - const countId = `end-count-${Math.random().toString(36).substring(2, 11)}`; - const countRadio = countContainer.createEl('input', { - type: 'radio', - value: 'count', - attr: { 'id': countId, 'name': 'end-mode' } - }); - countRadio.checked = this.endMode === 'count'; - countContainer.createEl('label', { - text: 'After ', - attr: { 'for': countId } - }); - - const countInput = countContainer.createEl('input', { - type: 'number', - cls: 'modal-form__input modal-form__input--count', - attr: { - min: '1', - max: '999', - value: this.rruleCount?.toString() || '1' - } - }); - - countContainer.createSpan({ text: ' occurrences', cls: 'modal-form__count-label' }); - - // Add help text for end conditions - this.createHelpText(endContainer, - 'The "until date" is when recurring instances stop being generated. This is separate from the task\'s due date, which is just metadata.'); - - // Event listeners - neverRadio.addEventListener('change', () => { - if (neverRadio.checked) { - this.endMode = 'never'; - this.rruleUntil = null; - this.rruleCount = null; - this.updateRRuleString(); - } - }); - - untilRadio.addEventListener('change', () => { - if (untilRadio.checked) { - this.endMode = 'until'; - this.rruleCount = null; - if (untilDateInput.value) { - this.rruleUntil = new Date(untilDateInput.value); - } - this.updateRRuleString(); - } - }); - - countRadio.addEventListener('change', () => { - if (countRadio.checked) { - this.endMode = 'count'; - this.rruleUntil = null; - this.rruleCount = parseInt(countInput.value) || 1; - this.updateRRuleString(); - } - }); - - untilDateInput.addEventListener('change', (e) => { - if (this.endMode === 'until') { - const value = (e.target as HTMLInputElement).value; - this.rruleUntil = value ? new Date(value) : null; - this.updateRRuleString(); - } - }); - - countInput.addEventListener('change', (e) => { - if (this.endMode === 'count') { - const value = parseInt((e.target as HTMLInputElement).value) || 1; - this.rruleCount = Math.max(1, Math.min(999, value)); - this.updateRRuleString(); - } - }); - } - - private createRRuleSummary(container: HTMLElement): void { - const summaryContainer = container.createDiv({ cls: 'modal-form__rrule-summary' }); - const summary = summaryContainer.createDiv({ - cls: 'modal-form__rrule-text', - text: this.getRRuleHumanText() - }); - - // Store reference for updating - (container as HTMLElement & { __rruleSummary?: HTMLElement }).__rruleSummary = summary; - } - - private updateRRuleString(): void { - this.recurrenceRule = this.generateRRuleString(); - - // Update summary if it exists - use modal-specific selector - const summaryEl = this.contentEl.querySelector('.modal-form__rrule-text') as HTMLElement; - if (summaryEl) { - summaryEl.textContent = this.getRRuleHumanText(); - } - - // Update interval container if frequency changed - use modal-specific selector - const modalContainer = this.contentEl.querySelector('.modal-form__interval-container') as HTMLElement; - if (modalContainer) { - const unitSpan = modalContainer.querySelector('.modal-form__interval-unit') as HTMLElement; - if (unitSpan) { - this.updateIntervalUnit(unitSpan); - } - } - } - -} \ No newline at end of file diff --git a/src/modals/MinimalistTaskCreationModal.ts b/src/modals/MinimalistTaskCreationModal.ts deleted file mode 100644 index 1058b90c..00000000 --- a/src/modals/MinimalistTaskCreationModal.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { App, Notice, setIcon } from 'obsidian'; -import TaskNotesPlugin from '../main'; -import { MinimalistTaskModal } from './MinimalistTaskModal'; -import { TaskInfo, TaskCreationData } from '../types'; -import { getCurrentTimestamp } from '../utils/dateUtils'; -import { generateTaskFilename, FilenameContext } from '../utils/filenameGenerator'; -import { calculateDefaultDate } from '../utils/helpers'; -import { NaturalLanguageParser, ParsedTaskData as NLParsedTaskData } from '../services/NaturalLanguageParser'; - -export interface TaskCreationOptions { - prePopulatedValues?: Partial; - onTaskCreated?: (task: TaskInfo) => void; -} - -export class MinimalistTaskCreationModal extends MinimalistTaskModal { - private options: TaskCreationOptions; - private nlParser: NaturalLanguageParser; - private nlInput: HTMLTextAreaElement; - private nlPreviewContainer: HTMLElement; - private nlButtonContainer: HTMLElement; - - constructor(app: App, plugin: TaskNotesPlugin, options: TaskCreationOptions = {}) { - super(app, plugin); - this.options = options; - this.nlParser = new NaturalLanguageParser( - plugin.settings.customStatuses, - plugin.settings.customPriorities, - plugin.settings.nlpDefaultToScheduled - ); - } - - getModalTitle(): string { - return 'Create task'; - } - - protected createModalContent(): void { - const { contentEl } = this; - contentEl.empty(); - - // Create main container - const container = contentEl.createDiv('minimalist-modal-container'); - - // Create NLP input as primary interface (if enabled) - if (this.plugin.settings.enableNaturalLanguageInput) { - this.createNaturalLanguageInput(container); - } else { - // Fall back to regular title input - this.createTitleInput(container); - } - - // Create action bar with icons - this.createActionBar(container); - - // Create collapsible details section - this.createDetailsSection(container); - - // Create save/cancel buttons - this.createActionButtons(container); - } - - private createNaturalLanguageInput(container: HTMLElement): void { - const nlContainer = container.createDiv('nl-input-container'); - - // Create minimalist input field - this.nlInput = nlContainer.createEl('textarea', { - cls: 'nl-input', - attr: { - placeholder: 'Buy groceries tomorrow at 3pm @home #errands\n\nAdd details here...', - rows: '3' - } - }); - - // Preview container - this.nlPreviewContainer = nlContainer.createDiv('nl-preview-container'); - - // Event listeners - this.nlInput.addEventListener('input', () => { - const input = this.nlInput.value.trim(); - if (input) { - this.updateNaturalLanguagePreview(input); - } else { - this.clearNaturalLanguagePreview(); - } - }); - - // Keyboard shortcuts - this.nlInput.addEventListener('keydown', (e) => { - const input = this.nlInput.value.trim(); - if (!input) return; - - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.handleSave(); - } else if (e.key === 'Tab' && e.shiftKey) { - e.preventDefault(); - this.parseAndFillForm(input); - } - }); - - // Focus the input - setTimeout(() => { - this.nlInput.focus(); - }, 100); - } - - private updateNaturalLanguagePreview(input: string): void { - if (!this.nlPreviewContainer) return; - - const parsed = this.nlParser.parseInput(input); - const previewData = this.nlParser.getPreviewData(parsed); - - if (previewData.length > 0 && parsed.title) { - this.nlPreviewContainer.empty(); - this.nlPreviewContainer.style.display = 'block'; - - previewData.forEach((item) => { - const previewItem = this.nlPreviewContainer.createDiv('nl-preview-item'); - previewItem.textContent = item.text; - }); - } else { - this.clearNaturalLanguagePreview(); - } - } - - private clearNaturalLanguagePreview(): void { - if (this.nlPreviewContainer) { - this.nlPreviewContainer.empty(); - this.nlPreviewContainer.style.display = 'none'; - } - } - - protected createActionBar(container: HTMLElement): void { - this.actionBar = container.createDiv('action-bar'); - - // NLP-specific icons (only if NLP is enabled) - if (this.plugin.settings.enableNaturalLanguageInput) { - // Fill form icon - this.createActionIcon(this.actionBar, 'wand', 'Fill form from natural language', (icon, event) => { - const input = this.nlInput?.value.trim(); - if (input) { - this.parseAndFillForm(input); - } - }); - - // Expand/collapse icon - this.createActionIcon(this.actionBar, this.isExpanded ? 'chevron-up' : 'chevron-down', - this.isExpanded ? 'Hide detailed options' : 'Show detailed options', (icon, event) => { - this.toggleDetailedForm(); - // Update icon and tooltip - const iconEl = icon.querySelector('.icon'); - if (iconEl) { - setIcon(iconEl as HTMLElement, this.isExpanded ? 'chevron-up' : 'chevron-down'); - } - icon.setAttribute('title', this.isExpanded ? 'Hide detailed options' : 'Show detailed options'); - }); - - // Add separator - const separator = this.actionBar.createDiv('action-separator'); - separator.style.width = '1px'; - separator.style.height = '24px'; - separator.style.backgroundColor = 'var(--background-modifier-border)'; - separator.style.margin = '0 var(--size-4-2)'; - } - - // Due date icon - this.createActionIcon(this.actionBar, 'calendar', 'Set due date', (icon, event) => { - this.showDateContextMenu(event, 'due'); - }, 'due-date'); - - // Scheduled date icon - this.createActionIcon(this.actionBar, 'calendar-clock', 'Set scheduled date', (icon, event) => { - this.showDateContextMenu(event, 'scheduled'); - }, 'scheduled-date'); - - // Status icon - this.createActionIcon(this.actionBar, 'dot-square', 'Set status', (icon, event) => { - this.showStatusContextMenu(event); - }, 'status'); - - // Priority icon - this.createActionIcon(this.actionBar, 'star', 'Set priority', (icon, event) => { - this.showPriorityContextMenu(event); - }, 'priority'); - - // Recurrence icon - this.createActionIcon(this.actionBar, 'refresh-ccw', 'Set recurrence', (icon, event) => { - this.showRecurrenceContextMenu(event); - }, 'recurrence'); - - // Update icon states based on current values - this.updateIconStates(); - } - - - private parseAndFillForm(input: string): void { - const parsed = this.nlParser.parseInput(input); - this.applyParsedData(parsed); - - // Expand the form to show filled fields - if (!this.isExpanded) { - this.expandModal(); - } - } - - private applyParsedData(parsed: NLParsedTaskData): void { - if (parsed.title) this.title = parsed.title; - if (parsed.status) this.status = parsed.status; - if (parsed.priority) this.priority = parsed.priority; - if (parsed.dueDate) this.dueDate = parsed.dueDate; - if (parsed.scheduledDate) this.scheduledDate = parsed.scheduledDate; - if (parsed.contexts) this.contexts = parsed.contexts.join(', '); - if (parsed.tags) this.tags = parsed.tags.join(', '); - if (parsed.details) this.details = parsed.details; - if (parsed.recurrence) this.recurrenceRule = parsed.recurrence; - - // Update form inputs if they exist - if (this.titleInput) this.titleInput.value = this.title; - if (this.detailsInput) this.detailsInput.value = this.details; - - // Update icon states - this.updateIconStates(); - } - - private toggleDetailedForm(): void { - if (this.isExpanded) { - // Collapse - this.isExpanded = false; - this.detailsContainer.style.display = 'none'; - this.containerEl.removeClass('expanded'); - } else { - // Expand - this.expandModal(); - } - } - - async initializeFormData(): Promise { - // Initialize with default values from settings - this.priority = this.plugin.settings.defaultTaskPriority; - this.status = this.plugin.settings.defaultTaskStatus; - - // Apply task creation defaults - const defaults = this.plugin.settings.taskCreationDefaults; - - // Apply default due date - this.dueDate = calculateDefaultDate(defaults.defaultDueDate); - - // Apply default scheduled date based on user settings - this.scheduledDate = calculateDefaultDate(defaults.defaultScheduledDate); - - // Apply default contexts and tags - this.contexts = defaults.defaultContexts || ''; - this.tags = defaults.defaultTags || ''; - - // Apply default time estimate - if (defaults.defaultTimeEstimate && defaults.defaultTimeEstimate > 0) { - this.timeEstimate = defaults.defaultTimeEstimate; - } - - // Apply pre-populated values if provided (overrides defaults) - if (this.options.prePopulatedValues) { - this.applyPrePopulatedValues(this.options.prePopulatedValues); - } - } - - private applyPrePopulatedValues(values: Partial): void { - if (values.title !== undefined) this.title = values.title; - if (values.due !== undefined) this.dueDate = values.due; - if (values.scheduled !== undefined) this.scheduledDate = values.scheduled; - if (values.priority !== undefined) this.priority = values.priority; - if (values.status !== undefined) this.status = values.status; - if (values.contexts !== undefined) { - this.contexts = values.contexts.join(', '); - } - if (values.tags !== undefined) { - this.tags = values.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', '); - } - if (values.timeEstimate !== undefined) this.timeEstimate = values.timeEstimate; - if (values.recurrence !== undefined && typeof values.recurrence === 'string') { - this.recurrenceRule = values.recurrence; - } - } - - async handleSave(): Promise { - // If NLP is enabled and there's content in the NL field, parse it first - if (this.plugin.settings.enableNaturalLanguageInput && this.nlInput) { - const nlContent = this.nlInput.value.trim(); - if (nlContent && !this.title.trim()) { - // Only auto-parse if no title has been manually entered - const parsed = this.nlParser.parseInput(nlContent); - this.applyParsedData(parsed); - } - } - - if (!this.validateForm()) { - new Notice('Please enter a task title'); - return; - } - - try { - const taskData = this.buildTaskData(); - const result = await this.plugin.taskService.createTask(taskData); - - new Notice(`Task "${result.taskInfo.title}" created successfully`); - - if (this.options.onTaskCreated) { - this.options.onTaskCreated(result.taskInfo); - } - - this.close(); - - } catch (error) { - console.error('Failed to create task:', error); - new Notice('Failed to create task: ' + error.message); - } - } - - private buildTaskData(): Partial { - const now = getCurrentTimestamp(); - - // Parse contexts and tags - const contextList = this.contexts - .split(',') - .map(c => c.trim()) - .filter(c => c.length > 0); - - const tagList = this.tags - .split(',') - .map(t => t.trim()) - .filter(t => t.length > 0); - - // Add the task tag if it's not already present - if (this.plugin.settings.taskTag && !tagList.includes(this.plugin.settings.taskTag)) { - tagList.push(this.plugin.settings.taskTag); - } - - const taskData: TaskCreationData = { - title: this.title.trim(), - due: this.dueDate || undefined, - scheduled: this.scheduledDate || undefined, - priority: this.priority, - status: this.status, - contexts: contextList.length > 0 ? contextList : undefined, - tags: tagList.length > 0 ? tagList : undefined, - timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined, - recurrence: this.recurrenceRule || undefined, - dateCreated: now, - dateModified: now - }; - - // Add details if provided - if (this.details.trim()) { - // You might want to add the details to the task content or as a separate field - // For now, we'll add it as part of the task description - taskData.details = this.details.trim(); - } - - return taskData; - } - - private generateFilename(taskData: TaskCreationData): string { - const context: FilenameContext = { - title: taskData.title || '', - status: taskData.status || 'open', - priority: taskData.priority || 'normal', - dueDate: taskData.due, - scheduledDate: taskData.scheduled - }; - - return generateTaskFilename(context, this.plugin.settings); - } - - // Override to prevent creating duplicate title input when NLP is enabled - protected createTitleInput(container: HTMLElement): void { - // Only create title input if NLP is disabled - if (!this.plugin.settings.enableNaturalLanguageInput) { - super.createTitleInput(container); - } - } -} diff --git a/src/modals/MinimalistTaskEditModal.ts b/src/modals/MinimalistTaskEditModal.ts deleted file mode 100644 index 18e1ee8f..00000000 --- a/src/modals/MinimalistTaskEditModal.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { App, Notice } from 'obsidian'; -import TaskNotesPlugin from '../main'; -import { MinimalistTaskModal } from './MinimalistTaskModal'; -import { TaskInfo } from '../types'; -import { getCurrentTimestamp } from '../utils/dateUtils'; -import { formatTimestampForDisplay } from '../utils/dateUtils'; - -export interface TaskEditOptions { - task: TaskInfo; - onTaskUpdated?: (task: TaskInfo) => void; -} - -export class MinimalistTaskEditModal extends MinimalistTaskModal { - private task: TaskInfo; - private options: TaskEditOptions; - private metadataContainer: HTMLElement; - - constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) { - super(app, plugin); - this.task = options.task; - this.options = options; - } - - getModalTitle(): string { - return 'Edit task'; - } - - async initializeFormData(): Promise { - // Initialize form fields with current task data - this.title = this.task.title; - this.dueDate = this.task.due || ''; - this.scheduledDate = this.task.scheduled || ''; - this.priority = this.task.priority; - this.status = this.task.status; - this.contexts = this.task.contexts ? this.task.contexts.join(', ') : ''; - this.tags = this.task.tags - ? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ') - : ''; - this.timeEstimate = this.task.timeEstimate || 0; - - // Handle recurrence - support both new rrule strings and old RecurrenceInfo objects - if (this.task.recurrence) { - if (typeof this.task.recurrence === 'string') { - this.recurrenceRule = this.task.recurrence; - } else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) { - // Legacy recurrence object - convert to string representation for display - this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence); - } - } else { - this.recurrenceRule = ''; - } - } - - private convertLegacyRecurrenceToString(recurrence: any): string { - // Convert legacy recurrence object to a readable string - // This is for display purposes in the edit modal - if (!recurrence.frequency) return ''; - - let recurrenceText = recurrence.frequency; - - if (recurrence.frequency === 'weekly' && recurrence.days_of_week) { - recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`; - } - - if (recurrence.frequency === 'monthly' && recurrence.day_of_month) { - recurrenceText += ` on day ${recurrence.day_of_month}`; - } - - return recurrenceText; - } - - onOpen() { - this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal'); - this.titleEl.textContent = this.getModalTitle(); - - this.initializeFormData().then(() => { - this.createModalContent(); - // Update icon states after creating the action bar - this.updateIconStates(); - this.focusTitleInput(); - }); - } - - protected createModalContent(): void { - const { contentEl } = this; - contentEl.empty(); - - // Create main container - const container = contentEl.createDiv('minimalist-modal-container'); - - // Create action bar with icons - this.createActionBar(container); - - // Create expanded details section (always expanded for editing) - this.createDetailsSection(container); - - // Create metadata section (for edit modal) - this.createMetadataSection(container); - - // Create save/cancel buttons - this.createActionButtons(container); - } - - - private createMetadataSection(container: HTMLElement): void { - this.metadataContainer = container.createDiv('metadata-container'); - - const metadataLabel = this.metadataContainer.createDiv('detail-label'); - metadataLabel.textContent = 'Task Information'; - - const metadataContent = this.metadataContainer.createDiv('metadata-content'); - - // Created date - if (this.task.dateCreated) { - const createdDiv = metadataContent.createDiv('metadata-item'); - createdDiv.createSpan('metadata-key').textContent = 'Created: '; - createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated); - } - - // Modified date - if (this.task.dateModified) { - const modifiedDiv = metadataContent.createDiv('metadata-item'); - modifiedDiv.createSpan('metadata-key').textContent = 'Modified: '; - modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified); - } - - // File path (if available) - if (this.task.path) { - const pathDiv = metadataContent.createDiv('metadata-item'); - pathDiv.createSpan('metadata-key').textContent = 'File: '; - pathDiv.createSpan('metadata-value').textContent = this.task.path; - } - } - - async handleSave(): Promise { - if (!this.validateForm()) { - new Notice('Please enter a task title'); - return; - } - - try { - const changes = this.getChanges(); - - if (Object.keys(changes).length === 0) { - new Notice('No changes to save'); - this.close(); - return; - } - - const updatedTask = await this.plugin.taskService.updateTask(this.task, changes); - - new Notice(`Task "${updatedTask.title}" updated successfully`); - - if (this.options.onTaskUpdated) { - this.options.onTaskUpdated(updatedTask); - } - - } catch (error) { - console.error('Failed to update task:', error); - new Notice('Failed to update task: ' + error.message); - } - } - - private getChanges(): Partial { - const changes: Partial = {}; - - // Check for changes and only include modified fields - if (this.title.trim() !== this.task.title) { - changes.title = this.title.trim(); - } - - if (this.dueDate !== (this.task.due || '')) { - changes.due = this.dueDate || undefined; - } - - if (this.scheduledDate !== (this.task.scheduled || '')) { - changes.scheduled = this.scheduledDate || undefined; - } - - if (this.priority !== this.task.priority) { - changes.priority = this.priority; - } - - if (this.status !== this.task.status) { - changes.status = this.status; - } - - // Parse and compare contexts - const newContexts = this.contexts - .split(',') - .map(c => c.trim()) - .filter(c => c.length > 0); - const oldContexts = this.task.contexts || []; - - if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) { - changes.contexts = newContexts.length > 0 ? newContexts : undefined; - } - - // Parse and compare tags - const newTags = this.tags - .split(',') - .map(t => t.trim()) - .filter(t => t.length > 0); - - // Add the task tag if it's not already present - if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) { - newTags.push(this.plugin.settings.taskTag); - } - - const oldTags = this.task.tags || []; - - if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) { - changes.tags = newTags.length > 0 ? newTags : undefined; - } - - // Compare time estimate - const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined; - const oldTimeEstimate = this.task.timeEstimate; - - if (newTimeEstimate !== oldTimeEstimate) { - changes.timeEstimate = newTimeEstimate; - } - - // Compare recurrence - const oldRecurrence = typeof this.task.recurrence === 'string' - ? this.task.recurrence - : ''; - - if (this.recurrenceRule !== oldRecurrence) { - changes.recurrence = this.recurrenceRule || undefined; - } - - // Always update modified timestamp if there are changes - if (Object.keys(changes).length > 0) { - changes.dateModified = getCurrentTimestamp(); - } - - return changes; - } - - - private async openTaskNote(): Promise { - try { - // Get the file from the task path - const file = this.app.vault.getAbstractFileByPath(this.task.path); - - if (!file) { - new Notice(`Could not find task file: ${this.task.path}`); - return; - } - - // Open the file in a new leaf - const leaf = this.app.workspace.getLeaf(true); - await leaf.openFile(file as any); - - // Close the modal - this.close(); - - } catch (error) { - console.error('Failed to open task note:', error); - new Notice('Failed to open task note'); - } - } - - // Start expanded for edit modal - override parent property - protected isExpanded = true; -} diff --git a/src/modals/TaskCreationModal.ts b/src/modals/TaskCreationModal.ts index 4f74a4db..03515478 100644 --- a/src/modals/TaskCreationModal.ts +++ b/src/modals/TaskCreationModal.ts @@ -1,1098 +1,379 @@ -import { App, Notice, TFile, Setting, Editor, setIcon } from 'obsidian'; +import { App, Notice, setIcon } from 'obsidian'; import TaskNotesPlugin from '../main'; -import { BaseTaskModal } from './BaseTaskModal'; -import { MINI_CALENDAR_VIEW_TYPE, TaskInfo } from '../types'; -import { ParsedTaskData } from '../utils/TasksPluginParser'; -import { getCurrentTimestamp, getDatePart } from '../utils/dateUtils'; +import { TaskModal } from './TaskModal'; +import { TaskInfo, TaskCreationData } from '../types'; +import { getCurrentTimestamp } from '../utils/dateUtils'; import { generateTaskFilename, FilenameContext } from '../utils/filenameGenerator'; import { calculateDefaultDate } from '../utils/helpers'; import { NaturalLanguageParser, ParsedTaskData as NLParsedTaskData } from '../services/NaturalLanguageParser'; -export interface TaskConversionOptions { - parsedData?: ParsedTaskData; - editor?: Editor; - lineNumber?: number; - selectionInfo?: { taskLine: string; details: string; startLine: number; endLine: number; originalContent: string[] }; - prefilledDetails?: string; +export interface TaskCreationOptions { + prePopulatedValues?: Partial; + onTaskCreated?: (task: TaskInfo) => void; } -export class TaskCreationModal extends BaseTaskModal { - details = ''; - - // UI elements for filename preview - private filenamePreview: HTMLElement | null = null; - private dueDateInput: HTMLInputElement | null = null; - - // Task conversion options - private conversionOptions: TaskConversionOptions; - - // Pre-populated values - private prePopulatedValues: Partial; - - // Natural language parsing - private nlParser: NaturalLanguageParser; - private nlInputContainer: HTMLElement | null = null; - private nlPreviewContainer: HTMLElement | null = null; - private detailedFormContainer: HTMLElement | null = null; - private isDetailedFormVisible = false; - private filenamePreviewContainer: HTMLElement | null = null; - - constructor(app: App, plugin: TaskNotesPlugin, prePopulatedValues?: Partial, conversionOptions?: TaskConversionOptions) { - super(app, plugin); - this.prePopulatedValues = prePopulatedValues || {}; - this.conversionOptions = conversionOptions || {}; - this.nlParser = new NaturalLanguageParser( - plugin.settings.customStatuses, - plugin.settings.customPriorities, - plugin.settings.nlpDefaultToScheduled - ); - - // If this is a task conversion, start with detailed form visible - if (this.conversionOptions.parsedData) { - this.isDetailedFormVisible = true; - } - } - - protected async initializeFormData(): Promise { - // Check if we have parsed data to pre-populate - if (this.conversionOptions.parsedData) { - this.populateFromParsedData(this.conversionOptions.parsedData); - } else { - // Initialize with default values - this.priority = this.plugin.settings.defaultTaskPriority; - this.status = this.plugin.settings.defaultTaskStatus; - - // Apply task creation defaults - const defaults = this.plugin.settings.taskCreationDefaults; - - // Apply default due date - this.dueDate = calculateDefaultDate(defaults.defaultDueDate); - - // Apply default scheduled date based on user settings - this.scheduledDate = calculateDefaultDate(defaults.defaultScheduledDate); - - // Apply default contexts and tags - this.contexts = defaults.defaultContexts || ''; - this.tags = defaults.defaultTags || ''; - - // Apply default time estimate - if (defaults.defaultTimeEstimate && defaults.defaultTimeEstimate > 0) { - this.timeEstimate = defaults.defaultTimeEstimate; - } - - // Apply default recurrence - if (defaults.defaultRecurrence && defaults.defaultRecurrence !== 'none') { - // For now, just set to no recurrence by default - rrule defaults can be added later - this.frequencyMode = 'NONE'; - this.recurrenceRule = ''; - } else { - this.frequencyMode = 'NONE'; - this.recurrenceRule = ''; - } - } - - // Apply pre-populated values if provided (overrides defaults) - if (this.prePopulatedValues) { - this.populateFromPrePopulatedValues(this.prePopulatedValues); - } - } - - private populateFromPrePopulatedValues(values: Partial): void { - if (values.title !== undefined) this.title = values.title; - if (values.status !== undefined) this.status = values.status; - if (values.priority !== undefined) this.priority = values.priority; - if (values.due !== undefined) { - this.dueDate = values.due; - } - if (values.scheduled !== undefined) { - this.scheduledDate = values.scheduled; - } - if (values.contexts !== undefined && values.contexts.length > 0) { - this.contexts = values.contexts.join(', '); - } - } - - private populateFromParsedData(data: ParsedTaskData): void { - // Reset all fields to ensure clean state for this conversion - this.title = data.title || ''; - this.priority = data.priority || this.plugin.settings.defaultTaskPriority; - this.status = data.status || this.plugin.settings.defaultTaskStatus; - this.dueDate = data.dueDate || ''; // Always reset due date - this.scheduledDate = ''; // Always reset scheduled date for converted tasks - // Use prefilled details from multi-line selection if available - this.details = this.conversionOptions.prefilledDetails || ''; - - // Time components will be set by the input fields automatically - - // Update input field if it exists - if (this.dueDateInput) { - this.dueDateInput.value = getDatePart(this.dueDate); - } - - // Set other optional fields if available - if (data.scheduledDate) { - // Note: TaskNotes doesn't have scheduled date, but we could use start date - // or add it to details - this.details = `Scheduled: ${data.scheduledDate}\n${this.details}`.trim(); - } - - if (data.startDate) { - // Note: TaskNotes doesn't have start date, add to details - this.details = `Start: ${data.startDate}\n${this.details}`.trim(); - } - - if (data.createdDate) { - this.details = `Originally created: ${data.createdDate}\n${this.details}`.trim(); - } - - if (data.doneDate) { - this.details = `Completed on: ${data.doneDate}\n${this.details}`.trim(); - } - - // Handle recurrence - for now, convert basic patterns to details - // Full rrule support for conversions can be added later - if (data.recurrence && data.recurrence !== 'none') { - this.details = `Recurrence: ${data.recurrence}\n${this.details}`.trim(); - - if (data.recurrenceData) { - const recurrenceDetails = []; - if (data.recurrenceData.days_of_week) { - recurrenceDetails.push(`Days: ${data.recurrenceData.days_of_week.join(', ')}`); - } - if (data.recurrenceData.day_of_month) { - recurrenceDetails.push(`Day of month: ${data.recurrenceData.day_of_month}`); - } - if (data.recurrenceData.month_of_year) { - recurrenceDetails.push(`Month: ${data.recurrenceData.month_of_year}`); - } - if (recurrenceDetails.length > 0) { - this.details = `${recurrenceDetails.join(', ')}\n${this.details}`.trim(); - } - } - } - } - - private async cacheAutocompleteData(): Promise { - try { - this.existingContexts = await this.getExistingContexts(); - this.existingTags = await this.getExistingTags(); - } catch (error) { - console.error('Error caching autocomplete data:', error); - } - } - - async onOpen() { - const { contentEl } = this; - contentEl.addClass('tasknotes-plugin', 'task-creation-modal'); - new Setting(contentEl) - .setName('Create new task') - .setHeading(); - - // Initialize form data - await this.initializeFormData(); - - // Cache autocomplete data - this.cacheAutocompleteData(); - - // Natural language input (if enabled) - if (this.plugin.settings.enableNaturalLanguageInput) { - this.createNaturalLanguageInput(contentEl); - } - - // Create container for detailed form - this.detailedFormContainer = contentEl.createDiv({ cls: 'detailed-form-container' }); - if (this.plugin.settings.enableNaturalLanguageInput && !this.conversionOptions.parsedData) { - this.detailedFormContainer.style.display = 'none'; - } - - // Title with character count and filename preview updates - this.createFormGroup(this.detailedFormContainer, 'Title', (container) => { - const inputContainer = container.createDiv({ cls: 'modal-form__input-container' }); - const input = inputContainer.createEl('input', { - type: 'text', - cls: 'modal-form__input modal-form__input--title', - attr: { - placeholder: 'Enter task title...', - maxlength: '200' - } - }); - const counter = inputContainer.createDiv({ - cls: 'modal-form__char-counter', - text: '0/200' - }); - - // Set initial value if pre-populated - if (this.title) { - input.value = this.title; - this.updateCharCounter(counter, this.title.length, 200); - this.updateFilenamePreview(); - } - - input.addEventListener('input', (e) => { - const value = (e.target as HTMLInputElement).value; - this.title = value; - this.updateCharCounter(counter, value.length, 200); - this.updateFilenamePreview(); - }); - - // Auto-focus on the title field - window.setTimeout(() => input.focus(), 50); - }); - - // Filename preview - this.filenamePreviewContainer = this.createFormGroup(contentEl, 'Filename preview', (container) => { - this.filenamePreview = container.createDiv({ - cls: 'task-creation-modal__preview', - text: 'Enter a title to see filename preview...' - }); - }); - - // Hide filename preview if natural language input is enabled and not converting a task - if (this.plugin.settings.enableNaturalLanguageInput && !this.conversionOptions.parsedData) { - this.filenamePreviewContainer.style.display = 'none'; - } - - // Details - this.createFormGroup(this.detailedFormContainer, 'Details', (container) => { - const textarea = container.createEl('textarea', { - cls: 'modal-form__input modal-form__input--textarea', - attr: { - placeholder: 'Optional details or description...', - rows: '3' - } - }); - - // Set initial value if pre-populated - if (this.details) { - textarea.value = this.details; - } - - textarea.addEventListener('input', (e) => { - this.details = (e.target as HTMLTextAreaElement).value; - }); - }); - - // Due Date - this.createFormGroup(this.detailedFormContainer, 'Due date', (container) => { - this.createDueDateInputWithRef(container); - }); - - // Scheduled Date - this.createFormGroup(this.detailedFormContainer, 'Scheduled date', (container) => { - this.createScheduledDateInput(container); - }); - - // Priority - this.createFormGroup(this.detailedFormContainer, 'Priority', (container) => { - this.createPriorityDropdown(container); - // Add filename preview update listener - const select = container.querySelector('select'); - if (select) { - select.addEventListener('change', () => { - this.updateFilenamePreview(); - }); - } - }); - - // Status - this.createFormGroup(this.detailedFormContainer, 'Status', (container) => { - this.createStatusDropdown(container); - // Add filename preview update listener - const select = container.querySelector('select'); - if (select) { - select.addEventListener('change', () => { - this.updateFilenamePreview(); - }); - } - }); - - // Contexts with autocomplete - this.createFormGroup(this.detailedFormContainer, 'Contexts', (container) => { - this.createAutocompleteInput( - container, - 'contexts', - () => this.existingContexts, - (value) => { this.contexts = value; } - ); - }); - - // Tags with autocomplete - this.createFormGroup(this.detailedFormContainer, 'Tags', (container) => { - this.createAutocompleteInput( - container, - 'tags', - () => this.existingTags, - (value) => { this.tags = value; } - ); - }); - - // Time Estimate - this.createFormGroup(this.detailedFormContainer, 'Time estimate', (container) => { - this.createTimeEstimateInput(container); - }); - - // Recurrence - create in detailedFormContainer like other fields - this.createFormGroup(this.detailedFormContainer, 'Recurrence', (container) => { - this.createRRuleBuilder(container); - }); - - // Action buttons - this.createActionButtons(this.detailedFormContainer); - - - // Keyboard shortcuts - contentEl.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - this.close(); - } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.createTask(); - } - }); - } - - protected createActionButtons(container: HTMLElement): void { - const buttonContainer = container.createDiv({ cls: 'modal-form__buttons' }); - - const createButton = buttonContainer.createEl('button', { - text: 'Create task', - cls: 'modal-form__button modal-form__button--primary' - }); - createButton.addEventListener('click', () => { - this.createTask(); - }); - - const cancelButton = buttonContainer.createEl('button', { - text: 'Cancel', - cls: 'modal-form__button modal-form__button--secondary' - }); - cancelButton.addEventListener('click', () => { - this.close(); - }); - } - - protected async handleSubmit(): Promise { - await this.createTask(); - } - - - private updateFilenamePreview() { - if (!this.filenamePreview) return; - - if (!this.title || !this.title.trim()) { - this.filenamePreview.textContent = 'Enter a title to see filename preview...'; - this.filenamePreview.className = 'task-creation-modal__preview'; - return; - } - - try { - const filenameContext: FilenameContext = { - title: this.title, - priority: this.priority, - status: this.status, - date: new Date(), - dueDate: this.dueDate, - scheduledDate: this.scheduledDate - }; - - const filename = generateTaskFilename(filenameContext, this.plugin.settings); - this.filenamePreview.textContent = `${filename}.md`; - this.filenamePreview.className = 'task-creation-modal__preview task-creation-modal__preview--valid'; - } catch (error) { - this.filenamePreview.textContent = 'Error generating filename preview'; - this.filenamePreview.className = 'task-creation-modal__preview task-creation-modal__preview--error'; - } - } - - async createTask() { - if (!await this.validateAndPrepareTask()) { - return; - } - - try { - const file = await this.performTaskCreation(); - - // If this is a conversion, replace the original line with a link - if (this.conversionOptions.editor && this.conversionOptions.lineNumber !== undefined) { - await this.replaceOriginalTaskLine(file); - } - - new Notice(`Task created: ${this.title}`); - this.close(); - } catch (error) { - console.error('Failed to create task:', error); - new Notice('Failed to create task. Please try again.'); - } - } - - /** - * Replace the original Tasks Plugin line(s) with a link to the new TaskNote - * Supports multi-line replacement when selection info is available - */ - private async replaceOriginalTaskLine(file: TFile): Promise { - if (!this.conversionOptions.editor || this.conversionOptions.lineNumber === undefined) { - return; - } - - const editor = this.conversionOptions.editor; - const lineNumber = this.conversionOptions.lineNumber; - - // Check if we have multi-line selection info - if (this.conversionOptions.selectionInfo) { - const { startLine, endLine, originalContent } = this.conversionOptions.selectionInfo; - - // Get original indentation from the first line - const originalIndentation = originalContent[0].match(/^(\s*)/)?.[1] || ''; - - // Create link text with proper indentation - const linkText = `${originalIndentation}- [[${file.path}|${this.title}]]`; - - // Replace the entire selection with the link - const rangeStart = { line: startLine, ch: 0 }; - const rangeEnd = { line: endLine, ch: editor.getLine(endLine).length }; - - editor.replaceRange(linkText, rangeStart, rangeEnd); - } else { - // Single line replacement (original behavior) - const linkText = `- [[${file.path}|${this.title}]]`; - - // Replace the entire line with the link - const lineStart = { line: lineNumber, ch: 0 }; - const lineEnd = { line: lineNumber, ch: editor.getLine(lineNumber).length }; - - editor.replaceRange(linkText, lineStart, lineEnd); - } - } - - /** - * Validate form and prepare for task creation - */ - private async validateAndPrepareTask(): Promise { - // Validate required fields - if (!this.title || !this.title.trim()) { - new Notice('Title is required'); - return false; - } - - if (this.title.length > 200) { - new Notice('Title is too long (max 200 characters)'); - return false; - } - - // Validate recurrence fields - if (this.frequencyMode === 'WEEKLY' && this.rruleByWeekday.length === 0) { - new Notice('Please select at least one day for weekly recurrence'); - return false; - } - - if (this.frequencyMode === 'MONTHLY') { - if (this.monthlyMode === 'day' && this.rruleByMonthday.length === 0) { - new Notice('Please specify a day for monthly recurrence'); - return false; - } - if (this.monthlyMode === 'weekday' && (this.rruleByWeekday.length === 0 || this.rruleBySetpos.length === 0)) { - new Notice('Please specify both position and weekday for monthly recurrence'); - return false; - } - } - - if (this.frequencyMode === 'YEARLY') { - if (this.rruleByMonth.length === 0 || this.rruleByMonthday.length === 0) { - new Notice('Please specify both month and day for yearly recurrence'); - return false; - } - } - - return true; - } - - /** - * Perform the actual task creation using the centralized service - */ - private async performTaskCreation(): Promise { - // Prepare contexts and tags arrays - const contextsArray = this.contexts ? this.contexts.split(',').map(c => c.trim()).filter(c => c) : []; - const tagsArray = this.tags ? this.tags.split(',').map(t => t.trim()).filter(t => t) : []; - - // Add task tag - tagsArray.unshift(this.plugin.settings.taskTag); - - // For manual task creation, don't associate with any parent note - const parentNote = ''; - - // Create TaskCreationData object with all the data - const taskData: import('../services/TaskService').TaskCreationData = { - title: this.title, - status: this.status, - priority: this.priority, - due: this.dueDate || undefined, - scheduled: this.scheduledDate || undefined, - contexts: contextsArray.length > 0 ? contextsArray : undefined, - tags: tagsArray, - timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined, - details: this.details && this.details.trim() ? this.details.trim() : undefined, - parentNote: parentNote, // Include parent note for template variable - dateCreated: getCurrentTimestamp(), - dateModified: getCurrentTimestamp() - }; - - // Add recurrence data as rrule string - if (this.recurrenceRule && this.recurrenceRule.trim()) { - taskData.recurrence = this.recurrenceRule; - } - - // Use the centralized task creation service - const { file } = await this.plugin.taskService.createTask(taskData); - - // If calendar view is open, update it to show the new task - const leaves = this.app.workspace.getLeavesOfType(MINI_CALENDAR_VIEW_TYPE); - if (leaves.length > 0) { - const calendarView = leaves[0].view as any; - if (calendarView && typeof calendarView.refresh === 'function') { - calendarView.refresh(); - } - } - - return file; - } - - /** - * Create due date input with reference for later updates - */ - private createDueDateInputWithRef(container: HTMLElement): void { - // Use the base implementation - this.createDueDateInput(container); - - // Get reference to the date input for compatibility - this.dueDateInput = container.querySelector('input[type="date"]') as HTMLInputElement; - } - - /** - * Create natural language input section - */ - private createNaturalLanguageInput(contentEl: HTMLElement): void { - this.nlInputContainer = contentEl.createDiv({ cls: 'nl-input-container' }); - - // Create minimalist input field without label - const inputContainer = this.nlInputContainer.createDiv({ cls: 'modal-form__input-container' }); - const textarea = inputContainer.createEl('textarea', { - cls: 'modal-form__input modal-form__input--textarea nl-input', - attr: { - placeholder: 'Buy groceries tomorrow at 3 in the afternoon @home #errands\n\nAdd details here...\n', - rows: '3' - } - }); - - // Minimal button container - const buttonContainer = inputContainer.createDiv({ - cls: 'nl-button-container', - attr: { style: 'display: flex; gap: 6px; margin-top: 6px; align-items: center;' } - }); - - const quickCreateButton = buttonContainer.createEl('button', { - cls: 'mod-cta nl-quick-create-button', - text: 'Create' - }); - - const parseButton = buttonContainer.createEl('button', { - cls: 'nl-parse-button', - text: 'Fill form' - }); - - const showDetailButton = buttonContainer.createEl('button', { - cls: 'nl-show-detail-button', - text: this.isDetailedFormVisible ? '−' : '+', - attr: { title: this.isDetailedFormVisible ? 'Hide detailed options' : 'Show detailed options' } - }); - - // Event listeners - textarea.addEventListener('input', () => { - const input = textarea.value.trim(); - if (input) { - this.updateNaturalLanguagePreview(input); - } else { - this.clearNaturalLanguagePreview(); - } - }); - - quickCreateButton.addEventListener('click', async () => { - const input = textarea.value.trim(); - if (input) { - await this.quickCreateTask(input); - } - }); - - parseButton.addEventListener('click', () => { - const input = textarea.value.trim(); - if (input) { - this.parseAndFillForm(input); - } - }); - - showDetailButton.addEventListener('click', () => { - this.toggleDetailedForm(); - showDetailButton.textContent = this.isDetailedFormVisible ? '−' : '+'; - showDetailButton.setAttribute('title', this.isDetailedFormVisible ? 'Hide detailed options' : 'Show detailed options'); - }); - - // Keyboard shortcuts - textarea.addEventListener('keydown', (e) => { - const input = textarea.value.trim(); - if (!input) return; - - // Ctrl/Cmd + Enter = Quick create - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - e.stopPropagation(); - // Use setTimeout to avoid async issues - window.setTimeout(async () => { - await this.quickCreateTask(input); - }, 0); - } - // Shift + Enter = Parse and fill form - else if (e.key === 'Enter' && e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - this.parseAndFillForm(input); - } - }); - - // Create preview container - this.nlPreviewContainer = this.nlInputContainer.createDiv({ cls: 'nl-preview-container' }); - this.nlPreviewContainer.style.display = 'none'; - - // Focus the textarea when natural language input is enabled (but not for conversions) - if (!this.isDetailedFormVisible) { - window.setTimeout(() => { - const nlTextarea = this.nlInputContainer?.querySelector('.nl-input') as HTMLTextAreaElement; - if (nlTextarea) { - nlTextarea.focus(); - } - }, 100); - } - } - - /** - * Update natural language preview - */ - private updateNaturalLanguagePreview(input: string): void { - if (!this.nlPreviewContainer) return; - - const parsed = this.nlParser.parseInput(input); - const previewData = this.nlParser.getPreviewData(parsed); - - if (previewData.length > 0 && parsed.title) { - this.nlPreviewContainer.empty(); - // No label for preview - let the content speak for itself - - const previewContent = this.nlPreviewContainer.createEl('div', { - cls: 'nl-preview-text' - }); - - // Create each preview item with proper icon on new lines - previewData.forEach((item, index) => { - const itemContainer = previewContent.createDiv({ cls: 'nl-preview-item' }); - const iconEl = itemContainer.createSpan({ cls: 'nl-preview-icon' }); - setIcon(iconEl, item.icon); - itemContainer.createSpan({ text: ` ${item.text}`, cls: 'nl-preview-text-content' }); - }); - - this.nlPreviewContainer.style.display = 'block'; - } else { - this.nlPreviewContainer.style.display = 'none'; - } - } - - /** - * Clear natural language preview - */ - private clearNaturalLanguagePreview(): void { - if (this.nlPreviewContainer) { - this.nlPreviewContainer.style.display = 'none'; - } - } - - /** - * Parse input and fill form fields - */ - private parseAndFillForm(input: string): void { - const parsed = this.nlParser.parseInput(input); - - // Fill form fields with parsed data - this.applyParsedData(parsed); - - // Show detailed form - this.showDetailedForm(); - - // Clear natural language input - const nlTextarea = this.nlInputContainer?.querySelector('.nl-input') as HTMLTextAreaElement; - if (nlTextarea) { - nlTextarea.value = ''; - } - this.clearNaturalLanguagePreview(); - } - - /** - * Apply parsed data to form fields - */ - private applyParsedData(parsed: NLParsedTaskData): void { - // Apply title - if (parsed.title) { - this.title = parsed.title; - const titleInput = this.detailedFormContainer?.querySelector('.modal-form__input--title') as HTMLInputElement; - if (titleInput) { - titleInput.value = parsed.title; - titleInput.dispatchEvent(new Event('input')); - } - } - - // Apply details - if (parsed.details) { - this.details = parsed.details; - - // Find details form group and textarea - const detailsFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const detailsGroup = detailsFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Details'); - }); - - if (detailsGroup) { - const textarea = detailsGroup.querySelector('textarea') as HTMLTextAreaElement; - if (textarea) { - textarea.value = this.details; - textarea.dispatchEvent(new Event('input')); - } - } - } - - // Apply due date and time - if (parsed.dueDate) { - this.dueDate = parsed.dueTime ? `${parsed.dueDate} ${parsed.dueTime}` : parsed.dueDate; - - // Find due date form group and inputs - const dueDateFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const dueDateGroup = dueDateFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Due date'); - }); - - if (dueDateGroup) { - const dateInput = dueDateGroup.querySelector('input[type="date"]') as HTMLInputElement; - const timeInput = dueDateGroup.querySelector('input[type="time"]') as HTMLInputElement; - - if (dateInput) { - dateInput.value = parsed.dueDate; - dateInput.dispatchEvent(new Event('change')); - } - if (timeInput && parsed.dueTime) { - timeInput.value = parsed.dueTime; - timeInput.dispatchEvent(new Event('change')); - } - } - } - - // Apply scheduled date and time - if (parsed.scheduledDate) { - this.scheduledDate = parsed.scheduledTime ? `${parsed.scheduledDate} ${parsed.scheduledTime}` : parsed.scheduledDate; - - // Find scheduled date form group and inputs - const scheduledDateFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const scheduledDateGroup = scheduledDateFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Scheduled date'); - }); - - if (scheduledDateGroup) { - const dateInput = scheduledDateGroup.querySelector('input[type="date"]') as HTMLInputElement; - const timeInput = scheduledDateGroup.querySelector('input[type="time"]') as HTMLInputElement; - - if (dateInput) { - dateInput.value = parsed.scheduledDate; - dateInput.dispatchEvent(new Event('change')); - } - if (timeInput && parsed.scheduledTime) { - timeInput.value = parsed.scheduledTime; - timeInput.dispatchEvent(new Event('change')); - } - } - } - - // Apply priority - if (parsed.priority) { - this.priority = parsed.priority; - - // Find priority form group and select - const priorityFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const priorityGroup = priorityFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Priority'); - }); - - if (priorityGroup) { - const select = priorityGroup.querySelector('select') as HTMLSelectElement; - if (select) { - select.value = parsed.priority; - select.dispatchEvent(new Event('change')); - } - } - } - - // Apply status - if (parsed.status) { - this.status = parsed.status; - - // Find status form group and select - const statusFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const statusGroup = statusFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Status'); - }); - - if (statusGroup) { - const select = statusGroup.querySelector('select') as HTMLSelectElement; - if (select) { - select.value = parsed.status; - select.dispatchEvent(new Event('change')); - } - } - } - - // Apply contexts - if (parsed.contexts.length > 0) { - this.contexts = parsed.contexts.join(', '); - - // Find contexts form group and input - const contextsFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const contextsGroup = contextsFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Contexts'); - }); - - if (contextsGroup) { - const input = contextsGroup.querySelector('input[type="text"]') as HTMLInputElement; - if (input) { - input.value = this.contexts; - input.dispatchEvent(new Event('input')); - } - } - } - - // Apply tags - if (parsed.tags.length > 0) { - this.tags = parsed.tags.join(', '); - - // Find tags form group and input - const tagsFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const tagsGroup = tagsFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Tags'); - }); - - if (tagsGroup) { - const input = tagsGroup.querySelector('input[type="text"]') as HTMLInputElement; - if (input) { - input.value = this.tags; - input.dispatchEvent(new Event('input')); - } - } - } - - // Apply time estimate - if (parsed.estimate) { - this.timeEstimate = parsed.estimate; - - // Find time estimate form group and input - const estimateFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const estimateGroup = estimateFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Time estimate'); - }); - - if (estimateGroup) { - const input = estimateGroup.querySelector('input[type="number"]') as HTMLInputElement; - if (input) { - input.value = parsed.estimate.toString(); - input.dispatchEvent(new Event('input')); - } - } - } - - // Apply recurrence - now supports rrule strings - if (parsed.recurrence && parsed.recurrence !== 'none') { - // If it's an rrule string, parse it and populate the recurrence UI - if (parsed.recurrence.startsWith('FREQ=')) { - this.recurrenceRule = parsed.recurrence; - this.parseRRuleString(parsed.recurrence); - - // Update the recurrence UI by triggering the frequency dropdown change - const recurrenceFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const recurrenceGroup = recurrenceFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Recurrence'); - }); - - if (recurrenceGroup) { - // Find and update the frequency dropdown - const frequencySelect = recurrenceGroup.querySelector('select') as HTMLSelectElement; - if (frequencySelect) { - frequencySelect.value = this.frequencyMode; - frequencySelect.dispatchEvent(new Event('change')); - } - - // After the frequency change event processes, update the interval input - window.setTimeout(() => { - const intervalInput = recurrenceGroup.querySelector('.modal-form__input--interval') as HTMLInputElement; - if (intervalInput) { - intervalInput.value = this.rruleInterval.toString(); - } - - // Update weekday checkboxes for weekly recurrence - if (this.frequencyMode === 'WEEKLY' && this.rruleByWeekday.length > 0) { - const dayCheckboxes = recurrenceGroup.querySelectorAll('.modal-form__day-input') as NodeListOf; - dayCheckboxes.forEach(checkbox => { - const dayLabel = checkbox.parentElement?.querySelector('.modal-form__day-label')?.textContent; - if (dayLabel) { - const fullDayName = this.getDayNameFromAbbreviation(dayLabel); - const isSelected = this.rruleByWeekday.some(wd => { - const dayNum = wd.weekday; - const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; - return dayNames[dayNum] === fullDayName; - }); - checkbox.checked = isSelected; - } - }); - } - }, 100); - } - } else { - // Legacy handling for simple recurrence patterns - add to details - let recurrenceText = `Recurrence: ${parsed.recurrence}`; - this.details = this.details ? `${recurrenceText}\n${this.details}` : recurrenceText; - - // Update details field if it exists - const detailsFormGroups = Array.from(this.detailedFormContainer?.querySelectorAll('.modal-form__group') || []); - const detailsGroup = detailsFormGroups.find(group => { - const label = group.querySelector('.modal-form__label'); - return label?.textContent?.includes('Details'); - }); - - if (detailsGroup) { - const textarea = detailsGroup.querySelector('textarea') as HTMLTextAreaElement; - if (textarea) { - textarea.value = this.details; - textarea.dispatchEvent(new Event('input')); - } - } - } - } - - // Update filename preview - this.updateFilenamePreview(); - } - - /** - * Toggle detailed form visibility - */ - private toggleDetailedForm(): void { - if (this.isDetailedFormVisible) { - this.hideDetailedForm(); - } else { - this.showDetailedForm(); - } - } - - /** - * Show detailed form - */ - private showDetailedForm(): void { - if (this.detailedFormContainer) { - this.detailedFormContainer.style.display = 'block'; - this.isDetailedFormVisible = true; - - // Show filename preview when detailed form is shown - if (this.filenamePreviewContainer) { - this.filenamePreviewContainer.style.display = 'block'; - } - - - // Update button text - const showDetailButton = this.nlInputContainer?.querySelector('.nl-show-detail-button') as HTMLButtonElement; - if (showDetailButton) { - showDetailButton.textContent = '−'; - showDetailButton.setAttribute('title', 'Hide detailed options'); - } - } - } - - - /** - * Hide detailed form - */ - private hideDetailedForm(): void { - if (this.detailedFormContainer) { - this.detailedFormContainer.style.display = 'none'; - this.isDetailedFormVisible = false; - - // Hide filename preview when detailed form is hidden (only if natural language is enabled) - if (this.plugin.settings.enableNaturalLanguageInput && this.filenamePreviewContainer) { - this.filenamePreviewContainer.style.display = 'none'; - } - - - // Update button text - const showDetailButton = this.nlInputContainer?.querySelector('.nl-show-detail-button') as HTMLButtonElement; - if (showDetailButton) { - showDetailButton.textContent = '+'; - showDetailButton.setAttribute('title', 'Show detailed options'); - } - } - } - - /** - * Convert day abbreviation to full day name - */ - private getDayNameFromAbbreviation(abbr: string): string { - const dayMap: Record = { - 'MON': 'Monday', - 'TUE': 'Tuesday', - 'WED': 'Wednesday', - 'THU': 'Thursday', - 'FRI': 'Friday', - 'SAT': 'Saturday', - 'SUN': 'Sunday' - }; - return dayMap[abbr.toUpperCase()] || abbr; - } - - /** - * Quick create task from natural language input - */ - private async quickCreateTask(input: string): Promise { - try { - // Disable the modal's form to prevent multiple submissions - const formElements = this.containerEl.querySelectorAll('input, button, textarea, select'); - formElements.forEach(el => (el as HTMLElement).style.pointerEvents = 'none'); - - // Parse the input and populate form fields - const parsed = this.nlParser.parseInput(input); - this.applyParsedData(parsed); - - // Wait for form population to complete (especially for days of week) - await new Promise(resolve => window.setTimeout(resolve, 350)); - - // Use the existing form submission logic - await this.handleSubmit(); - - // Close the modal - this.close(); - } catch (error) { - console.error('Error during quick task creation:', error); - new Notice('Failed to create task. Please try using the detailed form.'); - - // Re-enable form elements on error - const formElements = this.containerEl.querySelectorAll('input, button, textarea, select'); - formElements.forEach(el => (el as HTMLElement).style.pointerEvents = 'auto'); - } - } +export class TaskCreationModal extends TaskModal { + private options: TaskCreationOptions; + private nlParser: NaturalLanguageParser; + private nlInput: HTMLTextAreaElement; + private nlPreviewContainer: HTMLElement; + private nlButtonContainer: HTMLElement; + + constructor(app: App, plugin: TaskNotesPlugin, options: TaskCreationOptions = {}) { + super(app, plugin); + this.options = options; + this.nlParser = new NaturalLanguageParser( + plugin.settings.customStatuses, + plugin.settings.customPriorities, + plugin.settings.nlpDefaultToScheduled + ); + } + + getModalTitle(): string { + return 'Create task'; + } + + protected createModalContent(): void { + const { contentEl } = this; + contentEl.empty(); + + // Create main container + const container = contentEl.createDiv('minimalist-modal-container'); + + // Create NLP input as primary interface (if enabled) + if (this.plugin.settings.enableNaturalLanguageInput) { + this.createNaturalLanguageInput(container); + } else { + // Fall back to regular title input + this.createTitleInput(container); + } + + // Create action bar with icons + this.createActionBar(container); + + // Create collapsible details section + this.createDetailsSection(container); + + // Create save/cancel buttons + this.createActionButtons(container); + } + + private createNaturalLanguageInput(container: HTMLElement): void { + const nlContainer = container.createDiv('nl-input-container'); + + // Create minimalist input field + this.nlInput = nlContainer.createEl('textarea', { + cls: 'nl-input', + attr: { + placeholder: 'Buy groceries tomorrow at 3pm @home #errands\n\nAdd details here...', + rows: '3' + } + }); + + // Preview container + this.nlPreviewContainer = nlContainer.createDiv('nl-preview-container'); + + // Event listeners + this.nlInput.addEventListener('input', () => { + const input = this.nlInput.value.trim(); + if (input) { + this.updateNaturalLanguagePreview(input); + } else { + this.clearNaturalLanguagePreview(); + } + }); + + // Keyboard shortcuts + this.nlInput.addEventListener('keydown', (e) => { + const input = this.nlInput.value.trim(); + if (!input) return; + + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.handleSave(); + } else if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + this.parseAndFillForm(input); + } + }); + + // Focus the input + setTimeout(() => { + this.nlInput.focus(); + }, 100); + } + + private updateNaturalLanguagePreview(input: string): void { + if (!this.nlPreviewContainer) return; + + const parsed = this.nlParser.parseInput(input); + const previewData = this.nlParser.getPreviewData(parsed); + + if (previewData.length > 0 && parsed.title) { + this.nlPreviewContainer.empty(); + this.nlPreviewContainer.style.display = 'block'; + + previewData.forEach((item) => { + const previewItem = this.nlPreviewContainer.createDiv('nl-preview-item'); + previewItem.textContent = item.text; + }); + } else { + this.clearNaturalLanguagePreview(); + } + } + + private clearNaturalLanguagePreview(): void { + if (this.nlPreviewContainer) { + this.nlPreviewContainer.empty(); + this.nlPreviewContainer.style.display = 'none'; + } + } + + protected createActionBar(container: HTMLElement): void { + this.actionBar = container.createDiv('action-bar'); + + // NLP-specific icons (only if NLP is enabled) + if (this.plugin.settings.enableNaturalLanguageInput) { + // Fill form icon + this.createActionIcon(this.actionBar, 'wand', 'Fill form from natural language', (icon, event) => { + const input = this.nlInput?.value.trim(); + if (input) { + this.parseAndFillForm(input); + } + }); + + // Expand/collapse icon + this.createActionIcon(this.actionBar, this.isExpanded ? 'chevron-up' : 'chevron-down', + this.isExpanded ? 'Hide detailed options' : 'Show detailed options', (icon, event) => { + this.toggleDetailedForm(); + // Update icon and tooltip + const iconEl = icon.querySelector('.icon'); + if (iconEl) { + setIcon(iconEl as HTMLElement, this.isExpanded ? 'chevron-up' : 'chevron-down'); + } + icon.setAttribute('title', this.isExpanded ? 'Hide detailed options' : 'Show detailed options'); + }); + + // Add separator + const separator = this.actionBar.createDiv('action-separator'); + separator.style.width = '1px'; + separator.style.height = '24px'; + separator.style.backgroundColor = 'var(--background-modifier-border)'; + separator.style.margin = '0 var(--size-4-2)'; + } + + // Due date icon + this.createActionIcon(this.actionBar, 'calendar', 'Set due date', (icon, event) => { + this.showDateContextMenu(event, 'due'); + }, 'due-date'); + + // Scheduled date icon + this.createActionIcon(this.actionBar, 'calendar-clock', 'Set scheduled date', (icon, event) => { + this.showDateContextMenu(event, 'scheduled'); + }, 'scheduled-date'); + + // Status icon + this.createActionIcon(this.actionBar, 'dot-square', 'Set status', (icon, event) => { + this.showStatusContextMenu(event); + }, 'status'); + + // Priority icon + this.createActionIcon(this.actionBar, 'star', 'Set priority', (icon, event) => { + this.showPriorityContextMenu(event); + }, 'priority'); + + // Recurrence icon + this.createActionIcon(this.actionBar, 'refresh-ccw', 'Set recurrence', (icon, event) => { + this.showRecurrenceContextMenu(event); + }, 'recurrence'); + + // Update icon states based on current values + this.updateIconStates(); + } + + + private parseAndFillForm(input: string): void { + const parsed = this.nlParser.parseInput(input); + this.applyParsedData(parsed); + + // Expand the form to show filled fields + if (!this.isExpanded) { + this.expandModal(); + } + } + + private applyParsedData(parsed: NLParsedTaskData): void { + if (parsed.title) this.title = parsed.title; + if (parsed.status) this.status = parsed.status; + if (parsed.priority) this.priority = parsed.priority; + if (parsed.dueDate) this.dueDate = parsed.dueDate; + if (parsed.scheduledDate) this.scheduledDate = parsed.scheduledDate; + if (parsed.contexts) this.contexts = parsed.contexts.join(', '); + if (parsed.tags) this.tags = parsed.tags.join(', '); + if (parsed.details) this.details = parsed.details; + if (parsed.recurrence) this.recurrenceRule = parsed.recurrence; + + // Update form inputs if they exist + if (this.titleInput) this.titleInput.value = this.title; + if (this.detailsInput) this.detailsInput.value = this.details; + + // Update icon states + this.updateIconStates(); + } + + private toggleDetailedForm(): void { + if (this.isExpanded) { + // Collapse + this.isExpanded = false; + this.detailsContainer.style.display = 'none'; + this.containerEl.removeClass('expanded'); + } else { + // Expand + this.expandModal(); + } + } + + async initializeFormData(): Promise { + // Initialize with default values from settings + this.priority = this.plugin.settings.defaultTaskPriority; + this.status = this.plugin.settings.defaultTaskStatus; + + // Apply task creation defaults + const defaults = this.plugin.settings.taskCreationDefaults; + + // Apply default due date + this.dueDate = calculateDefaultDate(defaults.defaultDueDate); + + // Apply default scheduled date based on user settings + this.scheduledDate = calculateDefaultDate(defaults.defaultScheduledDate); + + // Apply default contexts and tags + this.contexts = defaults.defaultContexts || ''; + this.tags = defaults.defaultTags || ''; + + // Apply default time estimate + if (defaults.defaultTimeEstimate && defaults.defaultTimeEstimate > 0) { + this.timeEstimate = defaults.defaultTimeEstimate; + } + + // Apply pre-populated values if provided (overrides defaults) + if (this.options.prePopulatedValues) { + this.applyPrePopulatedValues(this.options.prePopulatedValues); + } + } + + private applyPrePopulatedValues(values: Partial): void { + if (values.title !== undefined) this.title = values.title; + if (values.due !== undefined) this.dueDate = values.due; + if (values.scheduled !== undefined) this.scheduledDate = values.scheduled; + if (values.priority !== undefined) this.priority = values.priority; + if (values.status !== undefined) this.status = values.status; + if (values.contexts !== undefined) { + this.contexts = values.contexts.join(', '); + } + if (values.tags !== undefined) { + this.tags = values.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', '); + } + if (values.timeEstimate !== undefined) this.timeEstimate = values.timeEstimate; + if (values.recurrence !== undefined && typeof values.recurrence === 'string') { + this.recurrenceRule = values.recurrence; + } + } + + async handleSave(): Promise { + // If NLP is enabled and there's content in the NL field, parse it first + if (this.plugin.settings.enableNaturalLanguageInput && this.nlInput) { + const nlContent = this.nlInput.value.trim(); + if (nlContent && !this.title.trim()) { + // Only auto-parse if no title has been manually entered + const parsed = this.nlParser.parseInput(nlContent); + this.applyParsedData(parsed); + } + } + + if (!this.validateForm()) { + new Notice('Please enter a task title'); + return; + } + + try { + const taskData = this.buildTaskData(); + const result = await this.plugin.taskService.createTask(taskData); + + new Notice(`Task "${result.taskInfo.title}" created successfully`); + + if (this.options.onTaskCreated) { + this.options.onTaskCreated(result.taskInfo); + } + + this.close(); + + } catch (error) { + console.error('Failed to create task:', error); + new Notice('Failed to create task: ' + error.message); + } + } + + private buildTaskData(): Partial { + const now = getCurrentTimestamp(); + + // Parse contexts and tags + const contextList = this.contexts + .split(',') + .map(c => c.trim()) + .filter(c => c.length > 0); + + const tagList = this.tags + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + + // Add the task tag if it's not already present + if (this.plugin.settings.taskTag && !tagList.includes(this.plugin.settings.taskTag)) { + tagList.push(this.plugin.settings.taskTag); + } + + const taskData: TaskCreationData = { + title: this.title.trim(), + due: this.dueDate || undefined, + scheduled: this.scheduledDate || undefined, + priority: this.priority, + status: this.status, + contexts: contextList.length > 0 ? contextList : undefined, + tags: tagList.length > 0 ? tagList : undefined, + timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined, + recurrence: this.recurrenceRule || undefined, + dateCreated: now, + dateModified: now + }; + + // Add details if provided + if (this.details.trim()) { + // You might want to add the details to the task content or as a separate field + // For now, we'll add it as part of the task description + taskData.details = this.details.trim(); + } + + return taskData; + } + + private generateFilename(taskData: TaskCreationData): string { + const context: FilenameContext = { + title: taskData.title || '', + status: taskData.status || 'open', + priority: taskData.priority || 'normal', + dueDate: taskData.due, + scheduledDate: taskData.scheduled + }; + + return generateTaskFilename(context, this.plugin.settings); + } + + // Override to prevent creating duplicate title input when NLP is enabled + protected createTitleInput(container: HTMLElement): void { + // Only create title input if NLP is disabled + if (!this.plugin.settings.enableNaturalLanguageInput) { + super.createTitleInput(container); + } + } } diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index fadc2918..a0c5d099 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -1,379 +1,267 @@ -import { App, Notice, TFile, Setting } from 'obsidian'; -import { RRule } from 'rrule'; +import { App, Notice } from 'obsidian'; import TaskNotesPlugin from '../main'; -import { BaseTaskModal } from './BaseTaskModal'; +import { TaskModal } from './TaskModal'; import { TaskInfo } from '../types'; +import { getCurrentTimestamp } from '../utils/dateUtils'; import { formatTimestampForDisplay } from '../utils/dateUtils'; -export class TaskEditModal extends BaseTaskModal { +export interface TaskEditOptions { task: TaskInfo; + onTaskUpdated?: (task: TaskInfo) => void; +} - constructor(app: App, plugin: TaskNotesPlugin, task: TaskInfo) { +export class TaskEditModal extends TaskModal { + private task: TaskInfo; + private options: TaskEditOptions; + private metadataContainer: HTMLElement; + + constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) { super(app, plugin); - this.task = task; + this.task = options.task; + this.options = options; } - protected async initializeFormData(): Promise { - // With native cache, task data is always current - no need to refetch + getModalTitle(): string { + return 'Edit task'; + } + + async initializeFormData(): Promise { // Initialize form fields with current task data this.title = this.task.title; - // Initialize date and time components properly this.dueDate = this.task.due || ''; this.scheduledDate = this.task.scheduled || ''; - // Time components will be handled by the input fields automatically - this.priority = this.task.priority; this.status = this.task.status; this.contexts = this.task.contexts ? this.task.contexts.join(', ') : ''; - this.tags = this.task.tags ? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ') : ''; - // Preserve the original time estimate value, ensuring it's not reset to 0 - this.timeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0; + this.tags = this.task.tags + ? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ') + : ''; + this.timeEstimate = this.task.timeEstimate || 0; + // Handle recurrence - support both new rrule strings and old RecurrenceInfo objects if (this.task.recurrence) { if (typeof this.task.recurrence === 'string') { - // New rrule string format this.recurrenceRule = this.task.recurrence; - this.parseRRuleString(this.task.recurrence); } else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) { - // Legacy RecurrenceInfo object - convert to rrule - this.convertLegacyRecurrenceToRRule(this.task.recurrence); + // Legacy recurrence object - convert to string representation for display + this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence); } } else { - // No recurrence - this.frequencyMode = 'NONE'; this.recurrenceRule = ''; } } - private convertLegacyRecurrenceToRRule(recurrence: any): void { - try { - // Map legacy frequency to new format - switch (recurrence.frequency) { - case 'daily': - this.frequencyMode = 'DAILY'; - this.rruleInterval = 1; - break; - case 'weekly': - this.frequencyMode = 'WEEKLY'; - this.rruleInterval = 1; - if (recurrence.days_of_week && recurrence.days_of_week.length > 0) { - // Convert legacy day abbreviations to RRule weekdays - const dayMap: Record = { - 'mon': RRule.MO, - 'tue': RRule.TU, - 'wed': RRule.WE, - 'thu': RRule.TH, - 'fri': RRule.FR, - 'sat': RRule.SA, - 'sun': RRule.SU - }; - this.rruleByWeekday = recurrence.days_of_week - .map((day: string) => dayMap[day.toLowerCase()]) - .filter((wd: any) => wd); - } - break; - case 'monthly': - this.frequencyMode = 'MONTHLY'; - this.rruleInterval = 1; - this.monthlyMode = 'day'; - if (recurrence.day_of_month) { - this.rruleByMonthday = [recurrence.day_of_month]; - } - break; - case 'yearly': - this.frequencyMode = 'YEARLY'; - this.rruleInterval = 1; - if (recurrence.month_of_year) { - this.rruleByMonth = [recurrence.month_of_year]; - } - if (recurrence.day_of_month) { - this.rruleByMonthday = [recurrence.day_of_month]; - } - break; - default: - this.frequencyMode = 'NONE'; - return; - } - - // Generate the rrule string from the converted data - this.recurrenceRule = this.generateRRuleString(); - } catch (error) { - console.error('Error converting legacy recurrence to rrule:', error); - this.frequencyMode = 'NONE'; - this.recurrenceRule = ''; + private convertLegacyRecurrenceToString(recurrence: any): string { + // Convert legacy recurrence object to a readable string + // This is for display purposes in the edit modal + if (!recurrence.frequency) return ''; + + let recurrenceText = recurrence.frequency; + + if (recurrence.frequency === 'weekly' && recurrence.days_of_week) { + recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`; } + + if (recurrence.frequency === 'monthly' && recurrence.day_of_month) { + recurrenceText += ` on day ${recurrence.day_of_month}`; + } + + return recurrenceText; } - async onOpen() { + onOpen() { + this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal'); + this.titleEl.textContent = this.getModalTitle(); + + this.initializeFormData().then(() => { + this.createModalContent(); + // Update icon states after creating the action bar + this.updateIconStates(); + this.focusTitleInput(); + }); + } + + protected createModalContent(): void { const { contentEl } = this; - contentEl.addClass('tasknotes-plugin', 'task-edit-modal'); - new Setting(contentEl) - .setName('Edit task') - .setHeading(); + contentEl.empty(); - // Initialize form data and cache autocomplete data - await this.initializeFormData(); - this.existingContexts = await this.getExistingContexts(); - this.existingTags = await this.getExistingTags(); + // Create main container + const container = contentEl.createDiv('minimalist-modal-container'); - // Title with character count - this.createFormGroup(contentEl, 'Title', (container) => { - this.createTitleInputWithCounter(container, 200); - - // Auto-focus on the title field for immediate editing - const input = container.querySelector('input'); - if (input) { - window.setTimeout(() => input.focus(), 50); - } - }); + // Create action bar with icons + this.createActionBar(container); - // Due Date - this.createFormGroup(contentEl, 'Due date', (container) => { - this.createDueDateInput(container); - }); + // Create expanded details section (always expanded for editing) + this.createDetailsSection(container); - // Scheduled Date - this.createFormGroup(contentEl, 'Scheduled date', (container) => { - this.createScheduledDateInput(container); - }); + // Create metadata section (for edit modal) + this.createMetadataSection(container); - // Priority - this.createFormGroup(contentEl, 'Priority', (container) => { - this.createPriorityDropdown(container); - }); - - // Status - this.createFormGroup(contentEl, 'Status', (container) => { - this.createStatusDropdown(container); - }); - - // Contexts with autocomplete - this.createFormGroup(contentEl, 'Contexts', (container) => { - this.createAutocompleteInput( - container, - 'contexts', - () => this.existingContexts, - (value) => { this.contexts = value; } - ); - }); - - // Tags with autocomplete - this.createFormGroup(contentEl, 'Tags', (container) => { - this.createAutocompleteInput( - container, - 'tags', - () => this.existingTags, - (value) => { this.tags = value; } - ); - }); - - // Time Estimate - this.createFormGroup(contentEl, 'Time estimate', (container) => { - this.createTimeEstimateInput(container); - }); - - // Recurrence - this.createFormGroup(contentEl, 'Recurrence', (container) => { - this.createRRuleBuilder(container); - }); - - // Metadata footer - this.createMetadataFooter(contentEl); - - // Action buttons - this.createActionButtons(contentEl); - - // Keyboard shortcuts - contentEl.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - this.close(); - } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.saveTask(); - } - }); + // Create save/cancel buttons + this.createActionButtons(container); } - protected createActionButtons(container: HTMLElement): void { - const buttonContainer = container.createDiv({ cls: 'modal-form__buttons' }); - - // Open Note button - const openButton = buttonContainer.createEl('button', { - text: 'Open note', - cls: 'modal-form__button modal-form__button--tertiary' - }); - openButton.addEventListener('click', () => { - this.openNote(); - }); - // Save button - const saveButton = buttonContainer.createEl('button', { - text: 'Save', - cls: 'modal-form__button modal-form__button--primary' - }); - saveButton.addEventListener('click', () => { - this.saveTask(); - }); + private createMetadataSection(container: HTMLElement): void { + this.metadataContainer = container.createDiv('metadata-container'); - // Cancel button - const cancelButton = buttonContainer.createEl('button', { - text: 'Cancel', - cls: 'modal-form__button modal-form__button--secondary' - }); - cancelButton.addEventListener('click', () => { - this.close(); - }); - } - - protected async handleSubmit(): Promise { - await this.saveTask(); - } - - private createMetadataFooter(container: HTMLElement): void { - const footer = container.createDiv({ cls: 'task-edit-modal__metadata' }); + const metadataLabel = this.metadataContainer.createDiv('detail-label'); + metadataLabel.textContent = 'Task Information'; - const metadataContainer = footer.createDiv({ cls: 'task-edit-modal__metadata-container' }); + const metadataContent = this.metadataContainer.createDiv('metadata-content'); + // Created date if (this.task.dateCreated) { - metadataContainer.createDiv({ - cls: 'task-edit-modal__metadata-item', - text: `Created: ${formatTimestampForDisplay(this.task.dateCreated, 'MMM d, yyyy \'at\' h:mm a')}` - }); + const createdDiv = metadataContent.createDiv('metadata-item'); + createdDiv.createSpan('metadata-key').textContent = 'Created: '; + createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated); } + // Modified date if (this.task.dateModified) { - metadataContainer.createDiv({ - cls: 'task-edit-modal__metadata-item', - text: `Modified: ${formatTimestampForDisplay(this.task.dateModified, 'MMM d, yyyy \'at\' h:mm a')}` - }); + const modifiedDiv = metadataContent.createDiv('metadata-item'); + modifiedDiv.createSpan('metadata-key').textContent = 'Modified: '; + modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified); + } + + // File path (if available) + if (this.task.path) { + const pathDiv = metadataContent.createDiv('metadata-item'); + pathDiv.createSpan('metadata-key').textContent = 'File: '; + pathDiv.createSpan('metadata-value').textContent = this.task.path; } } - private openNote(): void { - const file = this.app.vault.getAbstractFileByPath(this.task.path); - if (file instanceof TFile) { - this.app.workspace.getLeaf(false).openFile(file); - this.close(); - } - } - - async saveTask() { - // Validate required fields - if (!this.title || !this.title.trim()) { - new Notice('Title is required'); + async handleSave(): Promise { + if (!this.validateForm()) { + new Notice('Please enter a task title'); return; } - if (this.title.length > 200) { - new Notice('Title is too long (max 200 characters)'); - return; - } - - // Validate recurrence fields - if (this.frequencyMode === 'WEEKLY' && this.rruleByWeekday.length === 0) { - new Notice('Please select at least one day for weekly recurrence'); - return; - } - - if (this.frequencyMode === 'MONTHLY') { - if (this.monthlyMode === 'day' && this.rruleByMonthday.length === 0) { - new Notice('Please specify a day for monthly recurrence'); - return; - } - if (this.monthlyMode === 'weekday' && (this.rruleByWeekday.length === 0 || this.rruleBySetpos.length === 0)) { - new Notice('Please specify both position and weekday for monthly recurrence'); - return; - } - } - - if (this.frequencyMode === 'YEARLY') { - if (this.rruleByMonth.length === 0 || this.rruleByMonthday.length === 0) { - new Notice('Please specify both month and day for yearly recurrence'); - return; - } - } - try { - // Prepare contexts and tags arrays - const contextsArray = this.contexts ? this.contexts.split(',').map(c => c.trim()).filter(c => c) : []; - const tagsArray = this.tags ? this.tags.split(',').map(t => t.trim()).filter(t => t) : []; + const changes = this.getChanges(); - // Add task tag if not present - if (!tagsArray.includes(this.plugin.settings.taskTag)) { - tagsArray.unshift(this.plugin.settings.taskTag); - } - - // Detect changes by comparing current form values with original task - const updates: Partial = {}; - - // Check for changes in simple fields - if (this.title !== this.task.title) { - updates.title = this.title; - } - if (this.priority !== this.task.priority) { - updates.priority = this.priority; - } - if (this.status !== this.task.status) { - updates.status = this.status; - } - // Check for changes in date fields (compare full datetime values) - const currentDueDate = this.dueDate || ''; - const originalDueDate = this.task.due || ''; - if (currentDueDate !== originalDueDate) { - updates.due = this.dueDate || undefined; - } - - const currentScheduledDate = this.scheduledDate || ''; - const originalScheduledDate = this.task.scheduled || ''; - if (currentScheduledDate !== originalScheduledDate) { - updates.scheduled = this.scheduledDate || undefined; - } - - // Check time estimate with proper handling of undefined vs 0 - const originalTimeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0; - if (this.timeEstimate !== originalTimeEstimate) { - updates.timeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined; - } - - // Check for changes in contexts array - const originalContexts = this.task.contexts || []; - const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((val, index) => val === b[index]); - if (!arraysEqual(contextsArray, originalContexts)) { - updates.contexts = contextsArray.length > 0 ? contextsArray : undefined; - } - - // Check for changes in tags array - const originalTags = this.task.tags || []; - if (!arraysEqual(tagsArray, originalTags)) { - updates.tags = tagsArray; - } - - // Check for changes in recurrence - const currentRecurrenceRule = this.recurrenceRule || ''; - const originalRecurrenceRule = typeof this.task.recurrence === 'string' - ? this.task.recurrence - : ''; - - if (currentRecurrenceRule !== originalRecurrenceRule) { - updates.recurrence = currentRecurrenceRule || undefined; - } - - // If no changes detected, show message and return - if (Object.keys(updates).length === 0) { - new Notice('No changes detected'); + if (Object.keys(changes).length === 0) { + new Notice('No changes to save'); this.close(); return; } - // Call the centralized update service with current task state - await this.plugin.taskService.updateTask(this.task, updates); + const updatedTask = await this.plugin.taskService.updateTask(this.task, changes); - new Notice('Task updated successfully'); - this.close(); + new Notice(`Task "${updatedTask.title}" updated successfully`); + + if (this.options.onTaskUpdated) { + this.options.onTaskUpdated(updatedTask); + } } catch (error) { - console.error('Failed to save task:', error); - new Notice('Failed to save task. Please try again.'); + console.error('Failed to update task:', error); + new Notice('Failed to update task: ' + error.message); } } + + private getChanges(): Partial { + const changes: Partial = {}; + + // Check for changes and only include modified fields + if (this.title.trim() !== this.task.title) { + changes.title = this.title.trim(); + } + + if (this.dueDate !== (this.task.due || '')) { + changes.due = this.dueDate || undefined; + } + + if (this.scheduledDate !== (this.task.scheduled || '')) { + changes.scheduled = this.scheduledDate || undefined; + } + + if (this.priority !== this.task.priority) { + changes.priority = this.priority; + } + + if (this.status !== this.task.status) { + changes.status = this.status; + } + + // Parse and compare contexts + const newContexts = this.contexts + .split(',') + .map(c => c.trim()) + .filter(c => c.length > 0); + const oldContexts = this.task.contexts || []; + + if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) { + changes.contexts = newContexts.length > 0 ? newContexts : undefined; + } + + // Parse and compare tags + const newTags = this.tags + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + + // Add the task tag if it's not already present + if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) { + newTags.push(this.plugin.settings.taskTag); + } + + const oldTags = this.task.tags || []; + + if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) { + changes.tags = newTags.length > 0 ? newTags : undefined; + } + + // Compare time estimate + const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined; + const oldTimeEstimate = this.task.timeEstimate; + + if (newTimeEstimate !== oldTimeEstimate) { + changes.timeEstimate = newTimeEstimate; + } + + // Compare recurrence + const oldRecurrence = typeof this.task.recurrence === 'string' + ? this.task.recurrence + : ''; + + if (this.recurrenceRule !== oldRecurrence) { + changes.recurrence = this.recurrenceRule || undefined; + } + + // Always update modified timestamp if there are changes + if (Object.keys(changes).length > 0) { + changes.dateModified = getCurrentTimestamp(); + } + + return changes; + } + + + private async openTaskNote(): Promise { + try { + // Get the file from the task path + const file = this.app.vault.getAbstractFileByPath(this.task.path); + + if (!file) { + new Notice(`Could not find task file: ${this.task.path}`); + return; + } + + // Open the file in a new leaf + const leaf = this.app.workspace.getLeaf(true); + await leaf.openFile(file as any); + + // Close the modal + this.close(); + + } catch (error) { + console.error('Failed to open task note:', error); + new Notice('Failed to open task note'); + } + } + + // Start expanded for edit modal - override parent property + protected isExpanded = true; } diff --git a/src/modals/MinimalistTaskModal.ts b/src/modals/TaskModal.ts similarity index 99% rename from src/modals/MinimalistTaskModal.ts rename to src/modals/TaskModal.ts index 8d46e185..d54487b1 100644 --- a/src/modals/MinimalistTaskModal.ts +++ b/src/modals/TaskModal.ts @@ -6,7 +6,7 @@ import { StatusContextMenu } from '../components/StatusContextMenu'; import { RecurrenceContextMenu } from '../components/RecurrenceContextMenu'; import { getDatePart, getTimePart, combineDateAndTime } from '../utils/dateUtils'; -export abstract class MinimalistTaskModal extends Modal { +export abstract class TaskModal extends Modal { plugin: TaskNotesPlugin; // Core task properties diff --git a/src/types/taskConversion.ts b/src/types/taskConversion.ts new file mode 100644 index 00000000..780ad9a5 --- /dev/null +++ b/src/types/taskConversion.ts @@ -0,0 +1,16 @@ +import { Editor } from 'obsidian'; +import { ParsedTaskData } from '../utils/TasksPluginParser'; + +export interface TaskConversionOptions { + parsedData?: ParsedTaskData; + editor?: Editor; + lineNumber?: number; + selectionInfo?: { + taskLine: string; + details: string; + startLine: number; + endLine: number; + originalContent: string[] + }; + prefilledDetails?: string; +} \ No newline at end of file diff --git a/src/views/AdvancedCalendarView.ts b/src/views/AdvancedCalendarView.ts index fc9b28b2..b3e9752e 100644 --- a/src/views/AdvancedCalendarView.ts +++ b/src/views/AdvancedCalendarView.ts @@ -25,8 +25,8 @@ import { CalendarViewPreferences, ICSEvent } from '../types'; -import { MinimalistTaskCreationModal } from '../modals/MinimalistTaskCreationModal'; -import { MinimalistTaskEditModal } from '../modals/MinimalistTaskEditModal'; +import { TaskCreationModal } from '../modals/TaskCreationModal'; +import { TaskEditModal } from '../modals/TaskEditModal'; import { UnscheduledTasksSelectorModal, ScheduleTaskOptions } from '../modals/UnscheduledTasksSelectorModal'; import { TimeblockCreationModal } from '../modals/TimeblockCreationModal'; import { FilterBar } from '../ui/FilterBar'; @@ -827,7 +827,7 @@ export class AdvancedCalendarView extends ItemView { ? 60 // Default 1 hour for all-day events : Math.round((end.getTime() - start.getTime()) / (1000 * 60)); // Duration in minutes - const modal = new MinimalistTaskCreationModal(this.app, this.plugin, { + const modal = new TaskCreationModal(this.app, this.plugin, { prePopulatedValues: { scheduled: scheduledDate, timeEstimate: timeEstimate > 0 ? timeEstimate : 60 @@ -979,7 +979,7 @@ export class AdvancedCalendarView extends ItemView { } } else if (jsEvent.button === 0) { // Left click only: Open edit modal - const editModal = new MinimalistTaskEditModal(this.app, this.plugin, { task: taskInfo }); + const editModal = new TaskEditModal(this.app, this.plugin, { task: taskInfo }); editModal.open(); } } diff --git a/styles/modal-bem.css b/styles/modal-bem.css index 5162aaaf..5cef0bcb 100644 --- a/styles/modal-bem.css +++ b/styles/modal-bem.css @@ -117,8 +117,6 @@ BASE MODAL CONTAINER STYLES ===================================================================== */ -.tasknotes-plugin .task-creation-modal, -.tasknotes-plugin .task-edit-modal, .tasknotes-plugin .due-date-modal, .tasknotes-plugin .scheduled-date-modal, .tasknotes-plugin .task-selector-modal { @@ -134,8 +132,6 @@ MODAL HEADERS AND HEADINGS ===================================================================== */ -.tasknotes-plugin .task-creation-modal .setting-item-heading, -.tasknotes-plugin .task-edit-modal .setting-item-heading, .tasknotes-plugin .due-date-modal .setting-item-heading, .tasknotes-plugin .scheduled-date-modal .setting-item-heading { font-size: var(--tn-font-size-xl); @@ -665,62 +661,8 @@ color: var(--tn-text-normal); } -/* ===================================================================== - TASK CREATION MODAL SPECIFIC - ===================================================================== */ -.tasknotes-plugin .task-creation-modal__preview { - padding: var(--tn-spacing-md) var(--tn-spacing-lg); - background: var(--tn-bg-secondary); - border: 1px solid var(--tn-border-color); - border-radius: var(--tn-radius-sm); - font-size: var(--tn-font-size-sm); - font-family: var(--font-monospace); - color: var(--tn-text-muted); - margin-top: var(--tn-spacing-md); - word-break: break-all; -} - -.tasknotes-plugin .task-creation-modal__preview--valid { - background: rgba(var(--tn-interactive-success), 0.1); - border-color: var(--tn-interactive-success); - color: var(--tn-text-normal); -} - -.tasknotes-plugin .task-creation-modal__preview--error { - background: rgba(var(--tn-color-error), 0.1); - border-color: var(--tn-color-error); - color: var(--tn-color-error); -} - -/* ===================================================================== - TASK EDIT MODAL SPECIFIC - ===================================================================== */ - -.tasknotes-plugin .task-edit-modal__metadata { - margin-top: var(--tn-spacing-xxl); - padding-top: var(--tn-spacing-lg); - border-top: 1px solid var(--tn-border-color); -} - -.tasknotes-plugin .task-edit-modal__metadata-container { - display: flex; - flex-direction: column; - gap: var(--tn-spacing-md); -} - -.tasknotes-plugin .task-edit-modal__metadata-item { - font-size: var(--tn-font-size-sm); - color: var(--tn-text-muted); - line-height: 1.4; - padding: var(--tn-spacing-md) var(--tn-spacing-lg); - background: var(--tn-bg-secondary); - border: 1px solid var(--tn-border-color); - border-radius: var(--tn-radius-sm); - font-weight: var(--tn-font-weight-normal); -} - /* ===================================================================== QUICK DATE MODALS SPECIFIC ===================================================================== */ @@ -844,8 +786,6 @@ ===================================================================== */ @media (max-width: 768px) { - .tasknotes-plugin .task-creation-modal, - .tasknotes-plugin .task-edit-modal, .tasknotes-plugin .due-date-modal, .tasknotes-plugin .scheduled-date-modal { padding: var(--tn-spacing-lg); diff --git a/styles/minimalist-modal.css b/styles/task-modal.css similarity index 99% rename from styles/minimalist-modal.css rename to styles/task-modal.css index e131d2ce..6234445c 100644 --- a/styles/minimalist-modal.css +++ b/styles/task-modal.css @@ -1,5 +1,5 @@ /* ===================================================================== - MINIMALIST TASK MODAL - Google Keep/Todoist Inspired + TASK MODAL - Google Keep/Todoist Inspired ===================================================================== */ .tasknotes-plugin .minimalist-task-modal { diff --git a/tests/coverage-report.md b/tests/coverage-report.md index a1869f2c..3892de5b 100644 --- a/tests/coverage-report.md +++ b/tests/coverage-report.md @@ -20,7 +20,7 @@ This document provides a comprehensive analysis of the test suite implementation - ✅ **TaskCard.test.ts** - Task card rendering, interactions, status updates #### Modals (`tests/unit/modals/`) -- ✅ **BaseTaskModal.test.ts** - Base modal functionality, form utilities, RRule handling +- ✅ **TaskModal.test.ts** - Base modal functionality, form utilities, RRule handling - ✅ **TaskCreationModal.test.ts** - Task creation, validation, natural language processing ### 2. Integration Tests (`tests/integration/`) diff --git a/tests/unit/modals/BaseTaskModal.test.ts b/tests/unit/modals/BaseTaskModal.test.ts deleted file mode 100644 index a115aa2f..00000000 --- a/tests/unit/modals/BaseTaskModal.test.ts +++ /dev/null @@ -1,757 +0,0 @@ -/** - * BaseTaskModal Tests - * - * Tests for the BaseTaskModal base class including: - * - Form creation and management utilities - * - RRule parsing and generation - * - Date/time input handling - * - Autocomplete functionality - * - Validation helpers - * - Character counting utilities - * - Form state management - * - Error handling and edge cases - */ - -import { BaseTaskModal } from '../../../src/modals/BaseTaskModal'; -import { RRule, Frequency } from 'rrule'; -import { MockObsidian } from '../../__mocks__/obsidian'; -import type { App } from 'obsidian'; - -// Type helper to safely cast mock App to real App type -const createMockApp = (mockApp: any): App => mockApp as unknown as App; - -// Mock external dependencies -jest.mock('obsidian'); -jest.mock('rrule'); - -// Mock date utils -jest.mock('../../../src/utils/dateUtils', () => ({ - normalizeDateString: jest.fn((date) => date?.split('T')[0] || date), - validateDateInput: jest.fn(() => true), - hasTimeComponent: jest.fn((date) => date?.includes('T')), - getDatePart: jest.fn((date) => date?.split('T')[0] || date), - getTimePart: jest.fn((date) => date?.includes('T') ? '10:00' : null), - combineDateAndTime: jest.fn((date, time) => time ? `${date}T${time}` : date), - validateDateTimeInput: jest.fn(() => true) -})); - -// Concrete implementation of BaseTaskModal for testing -class TestTaskModal extends BaseTaskModal { - protected async initializeFormData(): Promise { - this.title = 'Test Task'; - this.priority = 'normal'; - this.status = 'open'; - } - - protected createActionButtons(container: HTMLElement): void { - const button = container.createEl('button', { text: 'Test Button' }); - button.addEventListener('click', () => this.handleSubmit()); - } - - protected async handleSubmit(): Promise { - // Test implementation - } -} - -describe('BaseTaskModal', () => { - let mockApp: App; - let mockPlugin: any; - let modal: TestTaskModal; - let container: HTMLElement; - - beforeEach(() => { - jest.clearAllMocks(); - MockObsidian.reset(); - - // Mock RRule constructor and toString to work properly with instance data - const mockRRule = jest.requireActual('../../../tests/__mocks__/rrule.ts'); - - // Store original static methods before overriding - const originalRRule = RRule; - const originalFromString = RRule.fromString; - - // Mock the constructor to properly set instance data - (RRule as any) = jest.fn().mockImplementation(function(this: any, options: any = {}) { - this.options = options; - return this; - }); - - // Restore static methods - (RRule as any).fromString = originalFromString; - Object.assign(RRule, originalRRule); - - // Set up the prototype with proper toString method - (RRule as any).prototype.toString = function(this: any) { - // Manually implement the toString logic - if (!this.options) { - return 'FREQ=DAILY;INTERVAL=1'; - } - - const { freq, interval = 1, byweekday, bymonthday, bymonth, bysetpos, until, count } = this.options; - let result = ''; - - const Frequency = mockRRule.Frequency; - switch (freq) { - case Frequency.DAILY: - result = 'FREQ=DAILY'; - break; - case Frequency.WEEKLY: - result = 'FREQ=WEEKLY'; - break; - case Frequency.MONTHLY: - result = 'FREQ=MONTHLY'; - break; - case Frequency.YEARLY: - result = 'FREQ=YEARLY'; - break; - default: - return 'FREQ=DAILY;INTERVAL=1'; - } - - if (interval && interval > 1) { - result += `;INTERVAL=${interval}`; - } - - if (byweekday && byweekday.length > 0) { - const dayMap = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; - const days = byweekday.map((w: any) => dayMap[w.weekday || w]).filter(Boolean); - if (days.length > 0) { - result += `;BYDAY=${days.join(',')}`; - } - } - - if (bymonthday && bymonthday.length > 0) { - result += `;BYMONTHDAY=${bymonthday.join(',')}`; - } - - if (bymonth && bymonth.length > 0) { - result += `;BYMONTH=${bymonth.join(',')}`; - } - - if (bysetpos && bysetpos.length > 0) { - result += `;BYSETPOS=${bysetpos.join(',')}`; - } - - if (until) { - const dateStr = until.toISOString().split('T')[0].replace(/-/g, ''); - result += `;UNTIL=${dateStr}`; - } - - if (count) { - result += `;COUNT=${count}`; - } - - return result; - }; - - // Create DOM container - document.body.innerHTML = ''; - container = document.createElement('div'); - document.body.appendChild(container); - - // Mock app - mockApp = createMockApp(MockObsidian.createMockApp()); - - // Mock plugin - mockPlugin = { - app: mockApp, - settings: { - taskTag: 'task', - customStatuses: [ - { value: 'open', label: 'Open' }, - { value: 'done', label: 'Done' } - ], - customPriorities: [ - { value: 'normal', label: 'Normal' }, - { value: 'high', label: 'High' } - ] - }, - cacheManager: { - getAllContexts: jest.fn().mockReturnValue(['work', 'home', 'urgent']), - getAllTags: jest.fn().mockReturnValue(['task', 'important', 'review']) - } - }; - - // @ts-ignore: Mock App type is compatible at runtime despite TypeScript warnings - modal = new TestTaskModal(createMockApp(mockApp), mockPlugin); - - // Mock console methods - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'debug').mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - document.body.innerHTML = ''; - if (modal) { - modal.close(); - } - }); - - describe('Modal Initialization', () => { - it('should initialize with default properties', () => { - expect(modal.title).toBe(''); - expect(modal.dueDate).toBe(''); - expect(modal.scheduledDate).toBe(''); - expect(modal.priority).toBe('normal'); - expect(modal.status).toBe('open'); - expect(modal.contexts).toBe(''); - expect(modal.tags).toBe(''); - expect(modal.timeEstimate).toBe(0); - expect((modal as any).frequencyMode).toBe('NONE'); - }); - - it('should initialize form data when called', async () => { - await (modal as any).initializeFormData(); - - expect(modal.title).toBe('Test Task'); - expect(modal.priority).toBe('normal'); - expect(modal.status).toBe('open'); - }); - }); - - describe('Cache Management', () => { - it('should get existing contexts', async () => { - const contexts = await modal.getExistingContexts(); - - expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled(); - expect(contexts).toEqual(['work', 'home', 'urgent']); - }); - - it('should get existing tags excluding task tag', async () => { - const tags = await modal.getExistingTags(); - - expect(mockPlugin.cacheManager.getAllTags).toHaveBeenCalled(); - expect(tags).toEqual(['important', 'review']); // 'task' filtered out - }); - }); - - describe('Day Name Conversion', () => { - it('should convert abbreviations to full names', () => { - const abbrs = ['mon', 'tue', 'fri']; - const fullNames = (modal as any).convertAbbreviationsToFullNames(abbrs); - - expect(fullNames).toEqual(['Monday', 'Tuesday', 'Friday']); - }); - - it('should convert full names to abbreviations', () => { - const fullNames = ['Monday', 'Tuesday', 'Friday']; - const abbrs = (modal as any).convertFullNamesToAbbreviations(fullNames); - - expect(abbrs).toEqual(['mon', 'tue', 'fri']); - }); - - it('should filter out invalid day names', () => { - const invalid = ['invalid', 'mon', 'not-a-day']; - const fullNames = (modal as any).convertAbbreviationsToFullNames(invalid); - - expect(fullNames).toEqual(['Monday']); - }); - }); - - describe('RRule Parsing', () => { - it('should parse daily recurrence rule', () => { - const rruleString = 'FREQ=DAILY;INTERVAL=2'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).frequencyMode).toBe('DAILY'); - expect((modal as any).rruleInterval).toBe(2); - }); - - it('should parse weekly recurrence rule', () => { - const rruleString = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).frequencyMode).toBe('WEEKLY'); - expect((modal as any).rruleInterval).toBe(1); - expect((modal as any).rruleByWeekday).toHaveLength(3); - }); - - it('should parse monthly recurrence rule', () => { - const rruleString = 'FREQ=MONTHLY;BYMONTHDAY=15'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).frequencyMode).toBe('MONTHLY'); - expect((modal as any).monthlyMode).toBe('day'); - expect((modal as any).rruleByMonthday).toEqual([15]); - }); - - it('should parse yearly recurrence rule', () => { - const rruleString = 'FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=15'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).frequencyMode).toBe('YEARLY'); - expect((modal as any).rruleByMonth).toEqual([6]); - expect((modal as any).rruleByMonthday).toEqual([15]); - }); - - it('should parse recurrence with end date', () => { - const until = new Date('2025-12-31'); - const rruleString = `FREQ=DAILY;UNTIL=${until.toISOString().split('T')[0].replace(/-/g, '')}`; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).endMode).toBe('until'); - expect((modal as any).rruleUntil).toBeInstanceOf(Date); - }); - - it('should parse recurrence with count', () => { - const rruleString = 'FREQ=DAILY;COUNT=10'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).endMode).toBe('count'); - expect((modal as any).rruleCount).toBe(10); - }); - - it('should handle empty rrule string', () => { - (modal as any).parseRRuleString(''); - - expect((modal as any).frequencyMode).toBe('NONE'); - expect((modal as any).rruleInterval).toBe(1); - }); - - it('should handle invalid rrule string', () => { - (modal as any).parseRRuleString('INVALID_RRULE'); - - expect((modal as any).frequencyMode).toBe('NONE'); - expect(console.error).toHaveBeenCalled(); - }); - - it('should determine monthly weekday mode', () => { - const rruleString = 'FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1'; - (modal as any).parseRRuleString(rruleString); - - expect((modal as any).frequencyMode).toBe('MONTHLY'); - expect((modal as any).monthlyMode).toBe('weekday'); - }); - }); - - describe('RRule Generation', () => { - it('should generate daily recurrence rule', () => { - (modal as any).frequencyMode = 'DAILY'; - (modal as any).rruleInterval = 3; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=DAILY'); - expect(rruleString).toContain('INTERVAL=3'); - }); - - it('should generate weekly recurrence rule', () => { - (modal as any).frequencyMode = 'WEEKLY'; - (modal as any).rruleByWeekday = [{ weekday: 0 }, { weekday: 2 }]; // Monday, Wednesday - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=WEEKLY'); - }); - - it('should generate monthly day recurrence rule', () => { - (modal as any).frequencyMode = 'MONTHLY'; - (modal as any).monthlyMode = 'day'; - (modal as any).rruleByMonthday = [15]; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=MONTHLY'); - }); - - it('should generate monthly weekday recurrence rule', () => { - (modal as any).frequencyMode = 'MONTHLY'; - (modal as any).monthlyMode = 'weekday'; - (modal as any).rruleByWeekday = [{ weekday: 0 }]; - (modal as any).rruleBySetpos = [1]; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=MONTHLY'); - }); - - it('should generate yearly recurrence rule', () => { - (modal as any).frequencyMode = 'YEARLY'; - (modal as any).rruleByMonth = [6]; - (modal as any).rruleByMonthday = [15]; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=YEARLY'); - }); - - it('should generate rule with until date', () => { - (modal as any).frequencyMode = 'DAILY'; - (modal as any).endMode = 'until'; - (modal as any).rruleUntil = new Date('2025-12-31'); - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=DAILY'); - }); - - it('should generate rule with count', () => { - (modal as any).frequencyMode = 'DAILY'; - (modal as any).endMode = 'count'; - (modal as any).rruleCount = 10; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toContain('FREQ=DAILY'); - }); - - it('should return empty string for NONE frequency', () => { - (modal as any).frequencyMode = 'NONE'; - - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toBe(''); - }); - - it('should handle generation errors gracefully', () => { - // Store the original RRule to restore it after the test - const originalRRule = RRule; - - // Override the working RRule mock to throw an error - (RRule as any) = jest.fn().mockImplementation(() => { - throw new Error('RRule creation failed'); - }); - - (modal as any).frequencyMode = 'DAILY'; - const rruleString = (modal as any).generateRRuleString(); - - expect(rruleString).toBe(''); - expect(console.error).toHaveBeenCalled(); - - // Restore the original RRule for subsequent tests - (global as any).RRule = originalRRule; - Object.assign(RRule, originalRRule); - }); - }); - - describe('RRule Human Text', () => { - it('should generate human text for valid rule', () => { - modal.recurrenceRule = 'FREQ=DAILY;INTERVAL=1'; - - const humanText = (modal as any).getRRuleHumanText(); - - expect(humanText).toBe('every day'); // Mocked response - }); - - it('should handle no recurrence rule', () => { - modal.recurrenceRule = ''; - - const humanText = (modal as any).getRRuleHumanText(); - - expect(humanText).toBe('No recurrence'); - }); - - it('should handle invalid recurrence rule', () => { - modal.recurrenceRule = 'INVALID_RULE'; - - const humanText = (modal as any).getRRuleHumanText(); - - expect(humanText).toBe('Invalid recurrence rule'); - expect(console.error).toHaveBeenCalled(); - }); - }); - - describe('Form Group Creation', () => { - it('should create form group with label and input container', () => { - const formGroup = (modal as any).createFormGroup(container, 'Test Label', (inputContainer: HTMLElement) => { - inputContainer.createEl('input', { type: 'text' }); - }); - - expect(formGroup.classList.contains('modal-form__group')).toBe(true); - - const label = formGroup.querySelector('.modal-form__label'); - expect(label?.textContent).toBe('Test Label'); - - const inputContainer = formGroup.querySelector('.modal-form__input-container'); - expect(inputContainer).toBeTruthy(); - - const input = inputContainer?.querySelector('input'); - expect(input).toBeTruthy(); - }); - - it('should create accessible form group with proper ARIA labels', () => { - const formGroup = (modal as any).createFormGroup(container, 'Accessible Label', (inputContainer: HTMLElement) => { - inputContainer.createEl('input', { type: 'text' }); - }); - - const label = formGroup.querySelector('.modal-form__label'); - const inputContainer = formGroup.querySelector('.modal-form__input-container'); - - expect(label?.id).toBeTruthy(); - expect(inputContainer?.getAttribute('aria-labelledby')).toBe(label?.id); - }); - }); - - describe('Autocomplete Input', () => { - beforeEach(() => { - (modal as any).existingContexts = ['work', 'home', 'urgent']; - (modal as any).existingTags = ['important', 'review']; - }); - - it('should create autocomplete input with proper attributes', async () => { - const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input'); - expect(input).toBeTruthy(); - expect(input?.getAttribute('aria-autocomplete')).toBe('list'); - expect(input?.getAttribute('role')).toBe('combobox'); - expect(input?.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should update field value on input', async () => { - const getSuggestions = jest.fn().mockResolvedValue([]); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - input.value = 'work, home'; - input.dispatchEvent(new Event('input')); - - expect(modal.contexts).toBe('work, home'); - expect(onChange).toHaveBeenCalledWith('work, home'); - }); - - it('should show suggestions on focus', async () => { - const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - input.focus(); - - // Allow async operations to complete - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(getSuggestions).toHaveBeenCalled(); - }); - - it('should hide suggestions on blur', async () => { - const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - input.focus(); - input.blur(); - - // Allow timeout to complete - await new Promise(resolve => setTimeout(resolve, 250)); - - const suggestions = container.querySelector('.modal-form__suggestions'); - expect(suggestions).toBeFalsy(); - }); - - it('should handle keyboard navigation in suggestions', async () => { - const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - - // Mock suggestions being shown - const suggestionsList = container.createDiv({ cls: 'modal-form__suggestions' }); - suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'work' }); - suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'home' }); - - const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); - input.dispatchEvent(arrowDownEvent); - - // Should not throw errors - expect(true).toBe(true); - }); - - it('should fetch fresh data when suggestions are empty', async () => { - const getSuggestions = jest.fn().mockResolvedValue([]); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - input.focus(); - - // Allow async operations to complete - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled(); - }); - }); - - describe('Suggestion Management', () => { - it('should show suggestions list', () => { - const input = container.createEl('input'); - const suggestions = ['work', 'home', 'urgent']; - const onChange = jest.fn(); - - (modal as any).showSuggestions(container, suggestions, input, onChange); - - const suggestionsList = container.querySelector('.modal-form__suggestions'); - expect(suggestionsList).toBeTruthy(); - expect(suggestionsList?.getAttribute('role')).toBe('listbox'); - expect(input.getAttribute('aria-expanded')).toBe('true'); - }); - - it('should hide suggestions when list is empty', () => { - const input = container.createEl('input'); - const suggestions: string[] = []; - const onChange = jest.fn(); - - (modal as any).showSuggestions(container, suggestions, input, onChange); - - const suggestionsList = container.querySelector('.modal-form__suggestions'); - expect(suggestionsList).toBeFalsy(); - expect(input.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should hide existing suggestions before showing new ones', () => { - const input = container.createEl('input'); - - // Create existing suggestions - container.createDiv({ cls: 'modal-form__suggestions' }); - - const suggestions = ['work', 'home']; - const onChange = jest.fn(); - - (modal as any).showSuggestions(container, suggestions, input, onChange); - - const suggestionsLists = container.querySelectorAll('.modal-form__suggestions'); - expect(suggestionsLists.length).toBe(1); // Should only have one list - }); - - it('should filter suggestions based on current input', () => { - const input = container.createEl('input') as HTMLInputElement; - input.value = 'work, ho'; // Partial input - - const suggestions = ['work', 'home', 'urgent']; - const onChange = jest.fn(); - - (modal as any).showSuggestions(container, suggestions, input, onChange); - - // Should show suggestions (exact filtering logic is in full implementation) - const suggestionsList = container.querySelector('.modal-form__suggestions'); - expect(suggestionsList).toBeTruthy(); - }); - - it('should handle suggestion selection', () => { - const input = container.createEl('input') as HTMLInputElement; - input.value = 'work, '; - - const onChange = jest.fn(); - - (modal as any).applySuggestion(input, 'home', onChange); - - expect(input.value).toBe('work, home'); - expect(onChange).toHaveBeenCalledWith('work, home'); - }); - }); - - describe('Error Handling', () => { - it('should handle errors in RRule parsing gracefully', () => { - // Mock RRule.fromString to throw error for this test - const originalFromString = RRule.fromString; - RRule.fromString = jest.fn().mockImplementationOnce(() => { - throw new Error('Invalid RRule'); - }); - - (modal as any).parseRRuleString('INVALID_RULE'); - - expect((modal as any).frequencyMode).toBe('NONE'); - expect(console.error).toHaveBeenCalled(); - - // Restore original fromString - RRule.fromString = originalFromString; - }); - - it('should handle errors in suggestion fetching', async () => { - mockPlugin.cacheManager.getAllContexts.mockImplementation(() => { - throw new Error('Cache error'); - }); - - const contexts = await modal.getExistingContexts(); - - // Should not throw, might return empty array or cached data - expect(Array.isArray(contexts)).toBe(true); - }); - - it('should handle malformed autocomplete input', async () => { - const getSuggestions = jest.fn().mockRejectedValue(new Error('Fetch error')); - const onChange = jest.fn(); - - await expect( - (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange) - ).resolves.not.toThrow(); - }); - }); - - describe('State Management', () => { - it('should reset RRule properties correctly', () => { - // Set some properties first - (modal as any).frequencyMode = 'WEEKLY'; - (modal as any).rruleInterval = 2; - (modal as any).rruleByWeekday = [{ weekday: 0 }]; - (modal as any).endMode = 'count'; - (modal as any).rruleCount = 10; - - (modal as any).resetRRuleProperties(); - - expect((modal as any).frequencyMode).toBe('NONE'); - expect((modal as any).rruleInterval).toBe(1); - expect((modal as any).rruleByWeekday).toEqual([]); - expect((modal as any).endMode).toBe('never'); - expect((modal as any).rruleCount).toBeNull(); - }); - - it('should maintain form field state correctly', () => { - modal.title = 'Updated Task'; - modal.priority = 'high'; - modal.contexts = 'work, urgent'; - - expect(modal.title).toBe('Updated Task'); - expect(modal.priority).toBe('high'); - expect(modal.contexts).toBe('work, urgent'); - }); - }); - - describe('Performance and Memory', () => { - it('should handle multiple rapid suggestion requests', async () => { - const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']); - const onChange = jest.fn(); - - await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange); - - const input = container.querySelector('input') as HTMLInputElement; - - // Rapid focus/blur cycles - for (let i = 0; i < 10; i++) { - input.focus(); - input.blur(); - } - - // Should not cause memory leaks or errors - expect(true).toBe(true); - }); - - it('should handle large suggestion lists efficiently', () => { - const largeSuggestions = Array.from({ length: 1000 }, (_, i) => `item-${i}`); - const input = container.createEl('input'); - const onChange = jest.fn(); - - const startTime = Date.now(); - (modal as any).showSuggestions(container, largeSuggestions, input, onChange); - const endTime = Date.now(); - - expect(endTime - startTime).toBeLessThan(100); // Should be fast - - const suggestionsList = container.querySelector('.modal-form__suggestions'); - expect(suggestionsList).toBeTruthy(); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/modals/TaskCreationModal.test.ts b/tests/unit/modals/TaskCreationModal.test.ts index 13630022..f5227619 100644 --- a/tests/unit/modals/TaskCreationModal.test.ts +++ b/tests/unit/modals/TaskCreationModal.test.ts @@ -5,7 +5,8 @@ * Jest interference issues and provide robust, reliable tests. */ -import { TaskCreationModal, TaskConversionOptions } from '../../../src/modals/TaskCreationModal'; +import { TaskCreationModal } from '../../../src/modals/TaskCreationModal'; +import { TaskConversionOptions } from '../../../src/types/taskConversion'; import { TaskInfo } from '../../../src/types'; import { ParsedTaskData } from '../../../src/utils/TasksPluginParser'; import { MockObsidian, Notice, TFile } from '../../__mocks__/obsidian';