diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 7f5c9c8d..0fa1afda 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -53,6 +53,8 @@ Example: ## Added +- (#1678) Added task-first timeblock creation and editing helpers, including prefilled task-title context-menu creation, prefilled task attachments, Add Task actions in the create/edit modals, auto-fill of empty titles from selected attachments, and configurable attachment search ordering + - Thanks to @Lorite for the fix - (#1619, #386, #621) Added drag-to-reorder for Kanban and Task List views, including grouped Task List moves, manual ordering support via the `tasknotes_manual_order` property, updated generated `.base` templates, and polished drag/drop feedback for swimlanes, filtered views, and interactive task controls - Thanks to @ac8318740 for the original contribution in PR #1619 - Thanks to @iholston and @dsebastien for opening #386 and #621 diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index d3589abe..2dcd3502 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -18,6 +18,8 @@ import { } from "../utils/dependencyUtils"; import { generateLink } from "../utils/linkUtils"; import { ContextMenu } from "./ContextMenu"; +import { buildTimeblockPrefillForTask } from "../utils/timeblockPrefillUtils"; +import { TimeblockCreationModal } from "../modals/TimeblockCreationModal"; export interface TaskContextMenuOptions { task: TaskInfo; @@ -312,6 +314,25 @@ export class TaskContextMenu { }); }); + // Create timeblock from task + if (plugin.settings.calendarViewSettings.enableTimeblocking) { + this.menu.addItem((item) => { + item.setTitle("Create timeblock"); + item.setIcon("calendar-plus"); + item.onClick(() => { + const prefill = buildTimeblockPrefillForTask(task, this.options.targetDate); + const modal = new TimeblockCreationModal(plugin.app, plugin, { + date: prefill.date, + startTime: prefill.startTime, + endTime: prefill.endTime, + prefilledTitle: task.title, + prefilledAttachmentPaths: [task.path], + }); + modal.open(); + }); + }); + } + // Archive/Unarchive this.menu.addItem((item) => { item.setTitle( diff --git a/src/modals/FileSelectorModal.ts b/src/modals/FileSelectorModal.ts index 8eec2ca5..86d09f01 100644 --- a/src/modals/FileSelectorModal.ts +++ b/src/modals/FileSelectorModal.ts @@ -24,6 +24,16 @@ export interface FileSelectorOptions { filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean); /** Folder to create new files in (default: vault root) */ newFileFolder?: string; + /** Sort order for results */ + sortOrder?: + | "name-asc" + | "name-desc" + | "path-asc" + | "path-desc" + | "created-recent" + | "created-oldest" + | "modified-recent" + | "modified-oldest"; } /** @@ -223,12 +233,39 @@ export class FileSelectorModal extends SuggestModal { ); } + const sortOrder = this.options.sortOrder || "name-asc"; + const sorted = [...filtered].sort((a, b) => { + const aCreated = a instanceof TFile ? a.stat.ctime : 0; + const bCreated = b instanceof TFile ? b.stat.ctime : 0; + const aModified = a instanceof TFile ? a.stat.mtime : 0; + const bModified = b instanceof TFile ? b.stat.mtime : 0; + switch (sortOrder) { + case "name-desc": + return b.name.localeCompare(a.name); + case "path-asc": + return a.path.localeCompare(b.path); + case "path-desc": + return b.path.localeCompare(a.path); + case "created-recent": + return bCreated - aCreated; + case "created-oldest": + return aCreated - bCreated; + case "modified-recent": + return bModified - aModified; + case "modified-oldest": + return aModified - bModified; + case "name-asc": + default: + return a.name.localeCompare(b.name); + } + }); + // Filter by query if (!query) { - return filtered.slice(0, 50); // Limit initial results + return sorted.slice(0, 50); // Limit initial results } - return filtered + return sorted .filter((file) => { const searchText = this.getSearchText(file).toLowerCase(); return searchText.includes(lowerQuery); @@ -338,6 +375,15 @@ export function openFileSelector( title?: string; filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean); newFileFolder?: string; + sortOrder?: + | "name-asc" + | "name-desc" + | "path-asc" + | "path-desc" + | "created-recent" + | "created-oldest" + | "modified-recent" + | "modified-oldest"; } ): void { const modal = new FileSelectorModal(plugin.app, plugin, { @@ -345,6 +391,7 @@ export function openFileSelector( title: options?.title, filter: options?.filter, newFileFolder: options?.newFileFolder, + sortOrder: options?.sortOrder, onResult: (result) => { if (result.type === "selected" || result.type === "created") { onChoose(result.file); diff --git a/src/modals/TimeblockCreationModal.ts b/src/modals/TimeblockCreationModal.ts index a361ab09..451751be 100644 --- a/src/modals/TimeblockCreationModal.ts +++ b/src/modals/TimeblockCreationModal.ts @@ -4,14 +4,16 @@ import { Setting, Notice, TAbstractFile, + TFile, parseYaml, stringifyYaml, setTooltip, } from "obsidian"; import TaskNotesPlugin from "../main"; -import { TimeBlock, DailyNoteFrontmatter } from "../types"; +import { TimeBlock, DailyNoteFrontmatter, TaskInfo } from "../types"; import { generateTimeblockId } from "../utils/helpers"; import { openFileSelector } from "./FileSelectorModal"; +import { openTaskSelector } from "./TaskSelectorWithCreateModal"; import { parseDateAsLocal } from "../utils/dateUtils"; import { createDailyNote, @@ -26,6 +28,7 @@ export interface TimeblockCreationOptions { startTime?: string; // HH:MM format endTime?: string; // HH:MM format prefilledTitle?: string; + prefilledAttachmentPaths?: string[]; } export class TimeblockCreationModal extends Modal { @@ -153,12 +156,24 @@ export class TimeblockCreationModal extends Modal { }, { placeholder: "Search files or type to create new...", filter: "all", + sortOrder: + this.plugin.settings.calendarViewSettings + .timeblockAttachmentSearchOrder, }); }); + }) + .addButton((button) => { + button + .setButtonText("Add task") + .setTooltip("Select task") + .onClick(() => { + void this.openTaskSelectorForTitle(); + }); }); // Attachments list container this.attachmentsList = contentEl.createDiv({ cls: "timeblock-attachments-list" }); + this.initializePrefilledAttachments(); this.renderAttachmentsList(); // Initialize empty state // Buttons @@ -211,6 +226,21 @@ export class TimeblockCreationModal extends Modal { createButton.style.opacity = isValid ? "1" : "0.5"; } + private initializePrefilledAttachments(): void { + const prefilledPaths = this.options.prefilledAttachmentPaths || []; + if (prefilledPaths.length === 0) { + return; + } + + const uniquePaths = new Set(prefilledPaths.filter((path) => typeof path === "string" && path.trim().length > 0)); + for (const path of uniquePaths) { + const file = this.app.vault.getAbstractFileByPath(path); + if (file) { + this.selectedAttachments.push(file); + } + } + } + private async handleSubmit(): Promise { try { // Validate inputs @@ -343,11 +373,47 @@ export class TimeblockCreationModal extends Modal { return; } + // If title is empty, default it to the selected attachment name. + if (this.titleInput && !this.titleInput.value.trim()) { + const derivedTitle = file instanceof TFile ? file.basename : file.name; + this.titleInput.value = derivedTitle; + this.validateForm(); + } + this.selectedAttachments.push(file); this.renderAttachmentsList(); new Notice(this.translate("notices.timeblockAttachmentAdded", { fileName: file.name })); } + private async openTaskSelectorForTitle(): Promise { + try { + const allTasks: TaskInfo[] = (await this.plugin.cacheManager.getAllTasks?.()) ?? []; + const candidates = allTasks.filter((task) => !task.archived); + + if (candidates.length === 0) { + new Notice("No tasks available to select"); + return; + } + + openTaskSelector(this.plugin, candidates, (selectedTask) => { + if (!selectedTask) return; + + this.titleInput.value = selectedTask.title || ""; + this.validateForm(); + + const taskFile = this.app.vault.getAbstractFileByPath(selectedTask.path); + if (taskFile) { + this.addAttachment(taskFile); + } + }, { + title: "Select task", + }); + } catch (error) { + console.error("Failed to open task selector for timeblock creation:", error); + new Notice("Failed to open task selector"); + } + } + private removeAttachment(file: TAbstractFile): void { this.selectedAttachments = this.selectedAttachments.filter( (existing) => existing.path !== file.path diff --git a/src/modals/TimeblockInfoModal.ts b/src/modals/TimeblockInfoModal.ts index 404301f8..ea9ca0de 100644 --- a/src/modals/TimeblockInfoModal.ts +++ b/src/modals/TimeblockInfoModal.ts @@ -11,6 +11,7 @@ import { } from "obsidian"; import TaskNotesPlugin from "../main"; import { openFileSelector } from "./FileSelectorModal"; +import { openTaskSelector } from "./TaskSelectorWithCreateModal"; import { getDailyNote, getAllDailyNotes, @@ -18,6 +19,7 @@ import { } from "obsidian-daily-notes-interface"; import { formatDateForStorage } from "../utils/dateUtils"; import { TranslationKey } from "../i18n"; +import { TaskInfo } from "../types"; export interface TimeBlock { title: string; @@ -140,8 +142,19 @@ export class TimeblockInfoModal extends Modal { }, { placeholder: "Search files or type to create new...", filter: "all", + sortOrder: + this.plugin.settings.calendarViewSettings + .timeblockAttachmentSearchOrder, }); }); + }) + .addButton((button) => { + button + .setButtonText("Add task") + .setTooltip("Select task") + .onClick(() => { + void this.openTaskSelectorForTitle(); + }); }); // Attachments list container @@ -220,11 +233,49 @@ export class TimeblockInfoModal extends Modal { return; } + // If title is empty, default it to the first selected attachment name. + if (this.titleInput && !this.titleInput.value.trim()) { + const derivedTitle = file instanceof TFile ? file.basename : file.name; + this.titleInput.value = derivedTitle; + this.timeblock.title = derivedTitle; + this.validateForm(); + } + this.selectedAttachments.push(file); this.renderAttachmentsList(); new Notice(this.translate("notices.timeblockAttachmentAdded", { fileName: file.name })); } + private async openTaskSelectorForTitle(): Promise { + try { + const allTasks: TaskInfo[] = (await this.plugin.cacheManager.getAllTasks?.()) ?? []; + const candidates = allTasks.filter((task) => !task.archived); + + if (candidates.length === 0) { + new Notice("No tasks available to select"); + return; + } + + openTaskSelector(this.plugin, candidates, (selectedTask) => { + if (!selectedTask) return; + + this.titleInput.value = selectedTask.title || ""; + this.timeblock.title = selectedTask.title || ""; + this.validateForm(); + + const taskFile = this.app.vault.getAbstractFileByPath(selectedTask.path); + if (taskFile) { + this.addAttachment(taskFile); + } + }, { + title: "Select task", + }); + } catch (error) { + console.error("Failed to open task selector for timeblock edit:", error); + new Notice("Failed to open task selector"); + } + } + private removeAttachment(file: TAbstractFile): void { this.selectedAttachments = this.selectedAttachments.filter( (existing) => existing.path !== file.path diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index a290f0a1..169ec7f8 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -169,6 +169,7 @@ export const DEFAULT_CALENDAR_VIEW_SETTINGS: CalendarViewSettings = { enableTimeblocking: false, // Disabled by default - toggleable feature defaultShowTimeblocks: true, defaultTimeblockColor: "#6366f1", + timeblockAttachmentSearchOrder: "name-asc", // Calendar behavior nowIndicator: true, selectMirror: true, diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index a4d68bdb..e79c2cea 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -601,6 +601,38 @@ export function renderFeaturesTab( ); if (plugin.settings.calendarViewSettings.enableTimeblocking) { + group.addSetting((setting) => + configureDropdownSetting(setting, { + name: "Attachment Search Order", + desc: "Controls how files are ordered in the Add Attachment search window for timeblocks.", + options: [ + { value: "name-asc", label: "Name (A to Z)" }, + { value: "name-desc", label: "Name (Z to A)" }, + { value: "path-asc", label: "Path (A to Z)" }, + { value: "path-desc", label: "Path (Z to A)" }, + { value: "created-recent", label: "Created (Newest first)" }, + { value: "created-oldest", label: "Created (Oldest first)" }, + { value: "modified-recent", label: "Modified (Newest first)" }, + { value: "modified-oldest", label: "Modified (Oldest first)" }, + ], + getValue: () => + plugin.settings.calendarViewSettings.timeblockAttachmentSearchOrder, + setValue: async (value: string) => { + plugin.settings.calendarViewSettings.timeblockAttachmentSearchOrder = + value as + | "name-asc" + | "name-desc" + | "path-asc" + | "path-desc" + | "created-recent" + | "created-oldest" + | "modified-recent" + | "modified-oldest"; + save(); + }, + }) + ); + group.addSetting((setting) => configureToggleSetting(setting, { name: translate("settings.features.timeblocking.showBlocksName"), diff --git a/src/types/settings.ts b/src/types/settings.ts index 3990428a..75bc4015 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -311,6 +311,16 @@ export interface GoogleCalendarExportSettings { defaultReminderMinutes: number | null; // Popup reminder X minutes before event (null = no reminder) } +export type TimeblockAttachmentSearchOrder = + | "name-asc" + | "name-desc" + | "path-asc" + | "path-desc" + | "created-recent" + | "created-oldest" + | "modified-recent" + | "modified-oldest"; + export interface CalendarViewSettings { // Default view defaultView: @@ -345,6 +355,7 @@ export interface CalendarViewSettings { enableTimeblocking: boolean; defaultShowTimeblocks: boolean; defaultTimeblockColor: string; + timeblockAttachmentSearchOrder: TimeblockAttachmentSearchOrder; // Calendar behavior nowIndicator: boolean; selectMirror: boolean; diff --git a/src/utils/timeblockPrefillUtils.ts b/src/utils/timeblockPrefillUtils.ts new file mode 100644 index 00000000..39ede525 --- /dev/null +++ b/src/utils/timeblockPrefillUtils.ts @@ -0,0 +1,54 @@ +import { TaskInfo } from "../types"; +import { + formatDateForStorage, + hasTimeComponent, + parseDateToLocal, +} from "./dateUtils"; + +export interface TimeblockPrefill { + date: string; + startTime: string; + endTime: string; +} + +function formatTimeForInput(date: Date): string { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; +} + +export function buildTimeblockPrefillForTask( + task: TaskInfo, + targetDate: Date +): TimeblockPrefill { + let startDate: Date | null = null; + + if (task.scheduled) { + try { + startDate = parseDateToLocal(task.scheduled); + } catch { + startDate = null; + } + } + + if (!startDate || isNaN(startDate.getTime())) { + startDate = new Date(targetDate); + } + + if (isNaN(startDate.getTime())) { + startDate = new Date(); + } + + if (!task.scheduled || !hasTimeComponent(task.scheduled)) { + startDate.setHours(9, 0, 0, 0); + } + + const durationMinutes = task.timeEstimate && task.timeEstimate > 0 ? task.timeEstimate : 60; + const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000); + + return { + date: formatDateForStorage(startDate), + startTime: formatTimeForInput(startDate), + endTime: formatTimeForInput(endDate), + }; +} diff --git a/tests/unit/utils/timeblockPrefillUtils.test.ts b/tests/unit/utils/timeblockPrefillUtils.test.ts new file mode 100644 index 00000000..f8f3b6e5 --- /dev/null +++ b/tests/unit/utils/timeblockPrefillUtils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@jest/globals"; + +import { TaskInfo } from "../../../src/types"; +import { + formatDateForStorage, + parseDateToLocal, +} from "../../../src/utils/dateUtils"; +import { buildTimeblockPrefillForTask } from "../../../src/utils/timeblockPrefillUtils"; + +function makeTask(overrides: Partial = {}): TaskInfo { + return { + path: "TaskNotes/Tasks/example.md", + title: "Example task", + status: "open", + priority: "normal", + ...overrides, + }; +} + +describe("buildTimeblockPrefillForTask", () => { + it("uses the scheduled date for date-only scheduled tasks", () => { + const prefill = buildTimeblockPrefillForTask( + makeTask({ + scheduled: "2026-03-10", + timeEstimate: 30, + }), + new Date(2026, 2, 29, 12, 0, 0, 0) + ); + + expect(prefill).toEqual({ + date: formatDateForStorage(parseDateToLocal("2026-03-10")), + startTime: "09:00", + endTime: "09:30", + }); + }); + + it("uses the scheduled datetime for time-aware scheduled tasks", () => { + const prefill = buildTimeblockPrefillForTask( + makeTask({ + scheduled: "2026-03-10T14:15", + timeEstimate: 45, + }), + new Date(2026, 2, 29, 12, 0, 0, 0) + ); + + expect(prefill).toEqual({ + date: formatDateForStorage(parseDateToLocal("2026-03-10T14:15")), + startTime: "14:15", + endTime: "15:00", + }); + }); + + it("falls back to the target date for unscheduled tasks", () => { + const prefill = buildTimeblockPrefillForTask( + makeTask(), + new Date(2026, 2, 29, 12, 0, 0, 0) + ); + const expectedFallbackDate = new Date(2026, 2, 29, 12, 0, 0, 0); + expectedFallbackDate.setHours(9, 0, 0, 0); + + expect(prefill).toEqual({ + date: formatDateForStorage(expectedFallbackDate), + startTime: "09:00", + endTime: "10:00", + }); + }); +});