mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Merge pull request #1679 from Lorite/fix_1678_open_the_timeblock_modal_with_the_task_title_prefilled_from_the_task_context_menu
# Conflicts: # docs/releases/unreleased.md
This commit is contained in:
commit
d3203b203f
10 changed files with 355 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<TAbstractFile> {
|
|||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
54
src/utils/timeblockPrefillUtils.ts
Normal file
54
src/utils/timeblockPrefillUtils.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
67
tests/unit/utils/timeblockPrefillUtils.test.ts
Normal file
67
tests/unit/utils/timeblockPrefillUtils.test.ts
Normal file
|
|
@ -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> = {}): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue