102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import type { CalendarWeek, CalendarEntry } from '../types/plan';
|
|
|
|
/**
|
|
* Returns the Monday date of a given ISO week number in a year.
|
|
* Week 1 = the week containing the first Thursday of the year (ISO 8601).
|
|
*/
|
|
function isoWeekToDate(year: number, week: number, dayOffset: number): Date {
|
|
// Jan 4 is always in week 1
|
|
const jan4 = new Date(year, 0, 4);
|
|
const dayOfWeek = jan4.getDay() || 7; // convert Sun=0 to 7
|
|
const monday = new Date(jan4);
|
|
monday.setDate(jan4.getDate() - (dayOfWeek - 1) + (week - 1) * 7);
|
|
monday.setDate(monday.getDate() + dayOffset);
|
|
return monday;
|
|
}
|
|
|
|
function formatICSDate(date: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return (
|
|
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
|
`T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
|
);
|
|
}
|
|
|
|
function escapeICS(str: string): string {
|
|
return str
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/;/g, '\\;')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/\n/g, '\\n');
|
|
}
|
|
|
|
function buildVEvent(
|
|
entry: CalendarEntry,
|
|
weekNumber: number,
|
|
year: number,
|
|
uid: string,
|
|
): string {
|
|
const startDate = isoWeekToDate(year, weekNumber, entry.dayOfWeek);
|
|
// All-day event: DTSTART is DATE only, DTEND is next day
|
|
const endDate = new Date(startDate);
|
|
endDate.setDate(endDate.getDate() + 1);
|
|
|
|
const formatDate = (d: Date) => {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
};
|
|
|
|
const lines = [
|
|
'BEGIN:VEVENT',
|
|
`UID:${uid}`,
|
|
`DTSTAMP:${formatICSDate(new Date())}Z`,
|
|
`DTSTART;VALUE=DATE:${formatDate(startDate)}`,
|
|
`DTEND;VALUE=DATE:${formatDate(endDate)}`,
|
|
`SUMMARY:${escapeICS(`[${entry.channel}] ${entry.title}`)}`,
|
|
entry.description ? `DESCRIPTION:${escapeICS(entry.description)}` : null,
|
|
`CATEGORIES:${escapeICS(entry.contentType.toUpperCase())}`,
|
|
`STATUS:${entry.status === 'published' ? 'CONFIRMED' : entry.status === 'approved' ? 'TENTATIVE' : 'NEEDS-ACTION'}`,
|
|
'END:VEVENT',
|
|
].filter(Boolean) as string[];
|
|
|
|
return lines.join('\r\n');
|
|
}
|
|
|
|
export function exportCalendarToICS(
|
|
weeks: CalendarWeek[],
|
|
calendarName = 'INFINITH 콘텐츠 캘린더',
|
|
): void {
|
|
const year = new Date().getFullYear();
|
|
|
|
const vEvents = weeks.flatMap((week) =>
|
|
week.entries.map((entry, idx) =>
|
|
buildVEvent(
|
|
entry,
|
|
week.weekNumber,
|
|
year,
|
|
`infinith-${week.weekNumber}-${entry.id ?? idx}@infinith.ai`,
|
|
),
|
|
),
|
|
);
|
|
|
|
const icsContent = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'PRODID:-//INFINITH//Marketing Content Calendar//KO',
|
|
`X-WR-CALNAME:${escapeICS(calendarName)}`,
|
|
'X-WR-TIMEZONE:Asia/Seoul',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:PUBLISH',
|
|
...vEvents,
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'infinith-content-calendar.ics';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|