Skip to content

Commit cfa4671

Browse files
committed
feat(calendar): add Google/Outlook Calendar OAuth integration
- Add CalendarAuthManager with PKCE OAuth 2.0 flow - Add Google and Outlook calendar provider implementations - Add calendar settings tab for OAuth connection management - Enhance CalendarComponent with external text event drag-drop - Improve task-to-calendar event click handling with ID fallback - Add calendar-provider types for provider configuration - Add calendar settings styles
1 parent 4d3116c commit cfa4671

File tree

11 files changed

+8298
-12
lines changed

11 files changed

+8298
-12
lines changed

src/components/features/calendar/index.ts

Lines changed: 251 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class CalendarComponent extends Component {
9090
public containerEl: HTMLElement;
9191
private tasks: Task[] = [];
9292
private events: CalendarEvent[] = [];
93+
private externalTextEvents: AdapterCalendarEvent[] = [];
9394
private currentViewMode: CalendarViewMode = "month";
9495
private currentDate: moment.Moment = moment();
9596

@@ -194,6 +195,7 @@ export class CalendarComponent extends Component {
194195
super.onload();
195196
this.processTasks();
196197
this.render();
198+
this.registerExternalTextDropHandlers();
197199

198200
// Listen for calendar views configuration changes
199201
this.registerEvent(
@@ -968,7 +970,14 @@ export class CalendarComponent extends Component {
968970
// ============================================
969971

970972
private handleTGEventClick(event: AdapterCalendarEvent) {
971-
const task = getTaskFromEvent(event as any);
973+
let task = getTaskFromEvent(event as any);
974+
975+
// Fallback: try to find task by ID if metadata is missing
976+
// This fixes issues where the event object is reconstructed/cloned by the library
977+
if (!task && event.id) {
978+
task = this.tasks.find((t) => t.id === event.id) || null;
979+
}
980+
972981
if (task) {
973982
this.params?.onTaskSelected?.(task);
974983
}
@@ -980,17 +989,28 @@ export class CalendarComponent extends Component {
980989
* Based on CalendarEventComponent implementation in event-renderer.ts
981990
*/
982991
private handleTGRenderEvent(ctx: EventRenderContext) {
983-
console.log(
984-
"[Calendar] handleTGRenderEvent called",
985-
ctx.event.id,
986-
ctx.viewType,
987-
);
988-
989992
// First render the default content
990993
ctx.defaultRender();
991994

992995
const { event, el } = ctx;
993996

997+
// Backup click handler to ensure selection always works
998+
// This guards against cases where the library's click handler fails
999+
if (!el.hasAttribute("data-click-handler")) {
1000+
el.setAttribute("data-click-handler", "true");
1001+
this.registerDomEvent(el, "click", (ev) => {
1002+
// Ignore clicks on the checkbox (it handles its own logic)
1003+
if (
1004+
(ev.target as HTMLElement).closest(
1005+
".task-list-item-checkbox",
1006+
)
1007+
) {
1008+
return;
1009+
}
1010+
this.handleTGEventClick(event);
1011+
});
1012+
}
1013+
9941014
// Skip if checkbox already added
9951015
if (el.querySelector(".task-list-item-checkbox")) {
9961016
return;
@@ -1081,15 +1101,22 @@ export class CalendarComponent extends Component {
10811101
newStart: Date | string,
10821102
newEnd: Date | string,
10831103
) {
1104+
if (event.metadata?.externalDrop === true) {
1105+
this.updateExternalTextEventTime(event, newStart, newEnd);
1106+
return;
1107+
}
1108+
10841109
const task = getTaskFromEvent(event as any);
10851110
if (!task) {
10861111
new Notice(t("Failed to update task: Task not found"));
10871112
return;
10881113
}
10891114

1090-
// Don't allow dragging ICS tasks (external calendar events)
1115+
// Don't allow dragging read-only ICS tasks (URL-based external calendar events)
1116+
// OAuth providers (Google, Outlook, Apple) support two-way sync and can be dragged
10911117
const isIcsTask = (task as any).source?.type === "ics";
1092-
if (isIcsTask) {
1118+
const isReadOnly = (task as any).readonly === true;
1119+
if (isIcsTask && isReadOnly) {
10931120
new Notice(
10941121
t(
10951122
"Cannot move external calendar events. Please update them in the original calendar.",
@@ -1309,16 +1336,26 @@ export class CalendarComponent extends Component {
13091336
newStart: Date | string,
13101337
newEnd: Date | string,
13111338
) {
1339+
if (event.metadata?.externalDrop === true) {
1340+
this.updateExternalTextEventTime(event, newStart, newEnd);
1341+
return;
1342+
}
1343+
13121344
const task = getTaskFromEvent(event as any);
13131345
if (!task) {
13141346
new Notice(t("Failed to update task: Task not found"));
13151347
return;
13161348
}
13171349

1350+
// Don't allow resizing read-only ICS tasks (URL-based external calendar events)
1351+
// OAuth providers (Google, Outlook, Apple) support two-way sync and can be resized
13181352
const isIcsTask = (task as any).source?.type === "ics";
1319-
if (isIcsTask) {
1353+
const isReadOnly = (task as any).readonly === true;
1354+
if (isIcsTask && isReadOnly) {
13201355
new Notice(
1321-
"In current version, cannot resize external calendar events",
1356+
t(
1357+
"Cannot resize external calendar events. Please update them in the original calendar.",
1358+
),
13221359
);
13231360
setTimeout(() => {
13241361
this.processTasks();
@@ -1559,6 +1596,205 @@ export class CalendarComponent extends Component {
15591596
}
15601597
}
15611598

1599+
private registerExternalTextDropHandlers(): void {
1600+
const supportsTextDrop = (
1601+
dt: DataTransfer | null,
1602+
): dt is DataTransfer => {
1603+
if (!dt) return false;
1604+
const types = Array.from(dt.types ?? []).map((t) =>
1605+
String(t).toLowerCase(),
1606+
);
1607+
const hasStringItem = Array.from(dt.items ?? []).some(
1608+
(item) => item.kind === "string",
1609+
);
1610+
return (
1611+
hasStringItem ||
1612+
types.includes("text/plain") ||
1613+
types.includes("text") ||
1614+
types.includes("text/unicode") ||
1615+
types.includes("text/html") ||
1616+
types.includes("text/uri-list")
1617+
);
1618+
};
1619+
1620+
const readDroppedText = (dt: DataTransfer): string => {
1621+
const raw =
1622+
dt.getData("text/plain") ||
1623+
dt.getData("text") ||
1624+
dt.getData("Text") ||
1625+
dt.getData("text/unicode") ||
1626+
dt.getData("text/uri-list") ||
1627+
"";
1628+
return raw.trim();
1629+
};
1630+
1631+
this.registerDomEvent(
1632+
this.viewContainerEl,
1633+
"dragover",
1634+
(e: DragEvent) => {
1635+
if (!supportsTextDrop(e.dataTransfer)) return;
1636+
e.preventDefault();
1637+
e.dataTransfer.dropEffect = "copy";
1638+
},
1639+
);
1640+
1641+
this.registerDomEvent(this.viewContainerEl, "drop", (e: DragEvent) => {
1642+
if (!supportsTextDrop(e.dataTransfer)) return;
1643+
1644+
const text = readDroppedText(e.dataTransfer);
1645+
if (!text) return;
1646+
1647+
const target = this.resolveExternalDropTarget(e);
1648+
if (!target) return;
1649+
1650+
e.preventDefault();
1651+
e.stopPropagation();
1652+
1653+
this.addExternalTextEvent(text, target);
1654+
});
1655+
}
1656+
1657+
private resolveExternalDropTarget(
1658+
e: DragEvent,
1659+
): { start: Date; end: Date; allDay: boolean } | null {
1660+
const targetEl = e.target instanceof HTMLElement ? e.target : null;
1661+
if (!targetEl) return null;
1662+
1663+
// Week/Day view: drop into a time column -> snap start time to the slot
1664+
const dayColumn = targetEl.closest<HTMLElement>(
1665+
".tg-day-column[data-date]",
1666+
);
1667+
if (dayColumn?.dataset.date) {
1668+
const rect = dayColumn.getBoundingClientRect();
1669+
const relY = e.clientY - rect.top;
1670+
1671+
// Keep in sync with our TGCalendar config (theme.cellHeight / draggable.snapMinutes)
1672+
const cellHeightPx = 60;
1673+
const snapMinutes = 15;
1674+
1675+
const rawMinutes = (relY / cellHeightPx) * 60;
1676+
const snappedMinutes = Math.max(
1677+
0,
1678+
Math.min(
1679+
1440,
1680+
Math.round(rawMinutes / snapMinutes) * snapMinutes,
1681+
),
1682+
);
1683+
1684+
const baseDate = dateFns.parseISO(dayColumn.dataset.date);
1685+
const start = dateFns.setMinutes(
1686+
dateFns.setHours(baseDate, Math.floor(snappedMinutes / 60)),
1687+
snappedMinutes % 60,
1688+
);
1689+
const end = dateFns.addMinutes(start, 30);
1690+
1691+
return { start, end, allDay: false };
1692+
}
1693+
1694+
// Month view: drop into a day cell -> place at that day (all-day)
1695+
const monthCell = targetEl.closest<HTMLElement>(".tg-month-cell");
1696+
const monthRow = monthCell?.closest<HTMLElement>(
1697+
".tg-month-row[data-date]",
1698+
);
1699+
if (monthCell && monthRow?.dataset.date) {
1700+
const cells = Array.from(
1701+
monthRow.querySelectorAll<HTMLElement>(".tg-month-cell"),
1702+
);
1703+
const index = cells.indexOf(monthCell);
1704+
if (index < 0) return null;
1705+
1706+
const rowStart = dateFns.parseISO(monthRow.dataset.date);
1707+
const date = addDays(rowStart, index);
1708+
const start = startOfDay(date);
1709+
1710+
return { start, end: start, allDay: true };
1711+
}
1712+
1713+
return null;
1714+
}
1715+
1716+
private addExternalTextEvent(
1717+
text: string,
1718+
target: { start: Date; end: Date; allDay: boolean },
1719+
): void {
1720+
const title =
1721+
text
1722+
.split(/\r?\n/)
1723+
.map((l) => l.trim())
1724+
.find((l) => l.length > 0) ?? t("New event");
1725+
1726+
const id = `external-drop:${Date.now()}:${Math.random().toString(16).slice(2)}`;
1727+
const toDateTime = (d: Date) => dateFns.format(d, "yyyy-MM-dd HH:mm");
1728+
1729+
const normalizedStart = target.allDay
1730+
? startOfDay(target.start)
1731+
: target.start;
1732+
const normalizedEnd = target.allDay
1733+
? startOfDay(target.end)
1734+
: target.end;
1735+
1736+
const event: AdapterCalendarEvent = {
1737+
id,
1738+
title,
1739+
start: toDateTime(normalizedStart),
1740+
end: toDateTime(normalizedEnd),
1741+
color: "var(--interactive-accent)",
1742+
allDay: target.allDay,
1743+
metadata: {
1744+
externalDrop: true,
1745+
rawText: text,
1746+
createdAt: Date.now(),
1747+
},
1748+
};
1749+
1750+
this.externalTextEvents.push(event);
1751+
this.tgCalendar?.addEvent(event);
1752+
}
1753+
1754+
/**
1755+
* Update the time of an external text event when dragged
1756+
*/
1757+
private updateExternalTextEventTime(
1758+
event: AdapterCalendarEvent,
1759+
newStart: Date | string,
1760+
newEnd: Date | string,
1761+
): void {
1762+
const index = this.externalTextEvents.findIndex(
1763+
(e) => e.id === event.id,
1764+
);
1765+
if (index < 0) return;
1766+
1767+
const startDate =
1768+
newStart instanceof Date ? newStart : new Date(newStart);
1769+
const endInput = newEnd ?? newStart;
1770+
const endDate =
1771+
endInput instanceof Date ? endInput : new Date(endInput);
1772+
1773+
if (
1774+
Number.isNaN(startDate.getTime()) ||
1775+
Number.isNaN(endDate.getTime())
1776+
) {
1777+
return;
1778+
}
1779+
1780+
const isAllDayEvent = event.allDay === true;
1781+
const normalizedStart = isAllDayEvent
1782+
? startOfDay(startDate)
1783+
: startDate;
1784+
const normalizedEnd = isAllDayEvent ? startOfDay(endDate) : endDate;
1785+
const toDateTime = (d: Date) => dateFns.format(d, "yyyy-MM-dd HH:mm");
1786+
1787+
const current = this.externalTextEvents[index];
1788+
if (!current) return;
1789+
1790+
this.externalTextEvents[index] = {
1791+
...current,
1792+
start: toDateTime(normalizedStart),
1793+
end: toDateTime(normalizedEnd),
1794+
allDay: isAllDayEvent,
1795+
};
1796+
}
1797+
15621798
// ============================================
15631799
// TGCalendar Interaction Handlers (v0.6.0+)
15641800
// ============================================
@@ -1775,7 +2011,10 @@ export class CalendarComponent extends Component {
17752011
const tasksWithDates = this.tasks.filter((task) =>
17762012
hasDateInformation(task),
17772013
);
1778-
return tasksToCalendarEvents(tasksWithDates);
2014+
return [
2015+
...tasksToCalendarEvents(tasksWithDates),
2016+
...this.externalTextEvents,
2017+
];
17792018
}
17802019

17812020
private getTasksForDate(date: Date): Task[] {

0 commit comments

Comments
 (0)