import { Injectable } from '@nestjs/common'; import { CalendarEvent } from '../models/calendar-event'; import { RedmineTypes } from '../models/redmine-types'; import * as Luxon from 'luxon'; import { IssuesService } from '../issues/issues.service'; import { randomUUID } from 'crypto'; /* BEGIN:VCALENDAR PRODID:-//Example Corp.//CalDAV Client//EN VERSION:2.0 BEGIN:VEVENT UID:1@example.com SUMMARY:One-off Meeting DTSTAMP:20041210T183904Z DTSTART:20041207T120000Z DTEND:20041207T130000Z END:VEVENT BEGIN:VEVENT UID:2@example.com SUMMARY:Weekly Meeting DTSTAMP:20041210T183838Z DTSTART:20041206T120000Z DTEND:20041206T130000Z RRULE:FREQ=WEEKLY END:VEVENT BEGIN:VEVENT UID:2@example.com SUMMARY:Weekly Meeting RECURRENCE-ID:20041213T120000Z DTSTAMP:20041210T183838Z DTSTART:20041213T130000Z DTEND:20041213T140000Z END:VEVENT END:VCALENDAR */ export type IssueAndEvent = { issue: RedmineTypes.ExtendedIssue; event: CalendarEvent; }; @Injectable() export class CalendarService { private defaultInterval = 30 * 24 * 60 * 60 * 1000; // 30 days constructor( public calendarEventsKey: string, private issuesService: IssuesService, ) {} /** * @param filter фильтр для первичной выборки данных из couchdb через nano.filter * @param interval период в милисекундах * @returns */ async getICalData(filter: any, interval?: number): Promise { const issues = await this.issuesService.find(filter); const actualEvents = this.getActualEvents(issues, interval); const formattedEvents = actualEvents .map((event) => { return this.generateICalendarEvent(event.issue, event.event); }) .filter((event) => { return !!event; }); const res = this.generateICalendar(formattedEvents); return res; } /** * @param filter фильтр для первичной выборки данных из couchdb через nano.filter * @param interval период в милисекундах * @returns */ async getRawData(filter: any, interval?: number): Promise { const issues = await this.issuesService.find(filter); return this.getActualEvents(issues, interval); } private getActualEvents( issues: RedmineTypes.ExtendedIssue[], interval?: number, ): IssueAndEvent[] { if (typeof interval !== 'number') interval = this.defaultInterval; const res: IssueAndEvent[] = []; for (let i = 0; i < issues.length; i++) { const issue = issues[i]; if ( !issue[this.calendarEventsKey] || issue[this.calendarEventsKey].length <= 0 ) { continue; } const events = issue[this.calendarEventsKey]; for (let j = 0; j < events.length; j++) { const event = events[j]; if (this.actualEvent(event, interval)) { res.push({ event: event, issue: issue }); } } } return res; } private actualEvent(event: CalendarEvent, interval: number): boolean { const now = Luxon.DateTime.now().toMillis(); const from = now - interval; const to = now + interval; return Boolean( (from <= event.fromTimestamp && event.fromTimestamp <= to) || (from <= event.toTimestamp && event.toTimestamp <= to), ); } private generateICalendarEvent( issue: RedmineTypes.Issue, data: CalendarEvent, ): string | null { if (!data) return null; return `BEGIN:VEVENT UID:${randomUUID()}@example.com DTSTAMP:${this.formatTimestamp(data.fromTimestamp, data.fullDay)} ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com DTSTART:${this.formatTimestamp(data.fromTimestamp, data.fullDay)} DTEND:${this.formatTimestamp(data.toTimestamp, data.fullDay)} SUMMARY:#${issue.id} - ${data.description} - ${issue.subject} END:VEVENT`; } private formatTimestamp(timestamp: number, fullDay?: boolean): string { const format: string = fullDay ? 'yyyyMMdd' : "yyyyMMdd'T'HHmmss'Z'"; let datetime = Luxon.DateTime.fromMillis(timestamp); if (!fullDay) datetime = datetime.setZone('utc'); return datetime.toFormat(format); } private generateICalendar(events: string[]): string { return `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN ${events.join('\n')} END:VCALENDAR`; } }