Выгрузка календарных событий в формате icalendar

This commit is contained in:
Gnedov Pavel 2023-08-24 18:40:16 +07:00
parent 4d693156d3
commit 4107ae84f3
5 changed files with 197 additions and 5 deletions

View file

@ -0,0 +1,58 @@
import { Controller, Get, Inject, Logger, Param, Query } from "@nestjs/common";
import { CalendarService } from "./calendar.service";
import nano from 'nano';
import { UNLIMITED } from "../consts/consts";
@Controller('calendar')
export class CalendarController {
private logger = new Logger(CalendarController.name);
constructor(
@Inject('CALENDAR_SERVICE')
private calendarService: CalendarService
) {}
@Get()
async get(@Param('filter') filter: any): Promise<string> {
return await this.calendarService.getICalData(filter);
}
@Get('/simple')
async simple(
@Query('project') project?: string,
@Query('category') category?: string
): Promise<string> {
const andSection: any[] = [
{
"closed_on": {
"$exists": false
}
}
];
if (project) {
andSection.push({
"project.name": {
"$in": [
project
]
}
});
}
if (category) {
andSection.push({
"category.name": {
"$in": [
category
]
}
});
}
const query: nano.MangoQuery = {
selector: {
"$and": andSection
},
limit: UNLIMITED
};
return await this.calendarService.getICalData(query);
}
}

View file

@ -0,0 +1,113 @@
import { Injectable, Logger } 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 interval = 30 * 24 * 60 * 60 * 1000; // 30 days
constructor(public calendarEventsKey: string, private issuesService: IssuesService) {}
async getICalData(filter: any): Promise<string> {
const issues = await this.issuesService.find(filter);
const actualEvents = this.getActualEvents(issues);
const formattedEvents = actualEvents.map((event) => {
return this.generateICalendarEvent(event.issue, event.event);
}).filter((event) => {
return !!event;
});
const res = this.generateICalendar(formattedEvents);
return res;
}
private getActualEvents(issues: RedmineTypes.ExtendedIssue[]): IssueAndEvent[] {
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)) res.push({event: event, issue: issue});
}
}
return res;
}
private actualEvent(event: CalendarEvent): boolean {
const now = Luxon.DateTime.now().toMillis();
const from = now - this.interval;
const to = now + this.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 {
let 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`;
}
}

View file

@ -29,6 +29,8 @@ import { TimePassedHighlightEnhancer } from './issue-enhancers/time-passed-highl
import { ListIssuesByFieldsWidgetService } from './project-dashboard/widgets/list-issues-by-fields.widget.service'; import { ListIssuesByFieldsWidgetService } from './project-dashboard/widgets/list-issues-by-fields.widget.service';
import { IssuesUpdaterService } from './issues-updater/issues-updater.service'; import { IssuesUpdaterService } from './issues-updater/issues-updater.service';
import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer'; import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer';
import { CalendarService } from './calendar/calendar.service';
import { CalendarController } from './calendar/calendar.controller';
@Module({}) @Module({})
export class EventEmitterModule implements OnModuleInit { export class EventEmitterModule implements OnModuleInit {
@ -83,6 +85,14 @@ export class EventEmitterModule implements OnModuleInit {
}, },
inject: [ConfigService], inject: [ConfigService],
}, },
{
provide: 'CALENDAR_SERVICE',
useFactory: (calendarEnhancer: CalendarEnhancer, issuesService: IssuesService): CalendarService => {
const calendarEventsKey = calendarEnhancer.calendarEventsKey;
return new CalendarService(calendarEventsKey, issuesService);
},
inject: ['CALENDAR_ENHANCER', IssuesService]
},
], ],
exports: [ exports: [
EventEmitterService, EventEmitterService,
@ -114,8 +124,12 @@ export class EventEmitterModule implements OnModuleInit {
provide: 'CALENDAR_ENHANCER', provide: 'CALENDAR_ENHANCER',
useExisting: 'CALENDAR_ENHANCER', useExisting: 'CALENDAR_ENHANCER',
}, },
{
provide: 'CALENDAR_SERVICE',
useExisting: 'CALENDAR_SERVICE',
},
], ],
controllers: [MainController, UsersController, IssuesController], controllers: [MainController, UsersController, IssuesController, CalendarController],
}; };
} }

View file

@ -49,7 +49,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
try { try {
res[this.calendarEventsKey] = this.getCalendarEvents(res); res[this.calendarEventsKey] = this.getCalendarEvents(res);
} catch (ex) { } catch (ex) {
this.logger.error(`Error at parsing calendar events, message - ${ex}`); this.logger.error(`Error at parsing calendar events, message - ${ex}: ${(ex as Error)?.stack}`);
return res; return res;
} }
this.logger.debug(`Calendar events for #${issue.id}: issue.${this.calendarEventsKey} = ${JSON.stringify(res[this.calendarEventsKey])}`); this.logger.debug(`Calendar events for #${issue.id}: issue.${this.calendarEventsKey} = ${JSON.stringify(res[this.calendarEventsKey])}`);
@ -147,7 +147,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
const lines = text.split('\n').map((line) => line.trim()); const lines = text.split('\n').map((line) => line.trim());
const calendarStartIndex = lines.indexOf('Календарь:'); const calendarStartIndex = lines.indexOf(this.descriptionCalendarParams.title);
if (calendarStartIndex < 0) return []; if (calendarStartIndex < 0) return [];
let index = calendarStartIndex + 1; let index = calendarStartIndex + 1;
@ -170,6 +170,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
} }
private extractFromList(line: string): string | null { private extractFromList(line: string): string | null {
if (!line || line.length <= 0) return null;
const regexp = new RegExp(this.descriptionCalendarParams.lineRegexp); const regexp = new RegExp(this.descriptionCalendarParams.lineRegexp);
const match = line.match(regexp); const match = line.match(regexp);
return match && match[0] ? match[0] : null; return match && match[0] ? match[0] : null;

View file

@ -7,8 +7,8 @@ export const DEFAULT_PARAMS: Moo.Rules = {
WS: /[ \t]+/, WS: /[ \t]+/,
delimiter: /(?: - |: )/, delimiter: /(?: - |: )/,
delimiter2: /(?:^\* |^- |(?!\d)T(?!>\d))/, delimiter2: /(?:^\* |^- |(?!\d)T(?!>\d))/,
date: /(?:(?:\d{2}\.\d{2}\.(?:\d{2}|\d{4}))|(?:\d{4}-\d{2}-\d{2}))/, date: /\b(?:(?:\d{2}\.\d{2}\.(?:\d{2}|\d{4}))|(?:\d{4}-\d{2}-\d{2}))\b/,
time: /(?:\d{2}\b:\d{2}:\d{2}|\d{2}:\d{2})/, time: /\b(?:\d{2}\b:\d{2}:\d{2}|\d{2}:\d{2})\b/,
word: /[\wА-Яа-я]+/, word: /[\wА-Яа-я]+/,
other: { match: /./, lineBreaks: true }, other: { match: /./, lineBreaks: true },
NL: { match: /\n/, lineBreaks: true }, NL: { match: /\n/, lineBreaks: true },
@ -136,6 +136,12 @@ export function parseToCalendarEvent(
to = date2.plus({ day: 1 }); to = date2.plus({ day: 1 });
} else if (fullDay) { } else if (fullDay) {
to = from.plus({ day: 1 }); to = from.plus({ day: 1 });
} else if (time2) {
to = date1.set({
hour: time2.hours,
minute: time2.minutes,
second: time2.seconds
});
} else { } else {
to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION)); to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION));
} }