From 4107ae84f31c8215c046a798275760dfd85e34cb Mon Sep 17 00:00:00 2001 From: Gnedov Pavel Date: Thu, 24 Aug 2023 18:40:16 +0700 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B5=20icalendar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/calendar/calendar.controller.ts | 58 +++++++++ .../src/calendar/calendar.service.ts | 113 ++++++++++++++++++ .../event-emitter/src/event-emitter.module.ts | 16 ++- .../src/issue-enhancers/calendar-enhancer.ts | 5 +- .../src/utils/string-with-dates-parser.ts | 10 +- 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 libs/event-emitter/src/calendar/calendar.controller.ts create mode 100644 libs/event-emitter/src/calendar/calendar.service.ts diff --git a/libs/event-emitter/src/calendar/calendar.controller.ts b/libs/event-emitter/src/calendar/calendar.controller.ts new file mode 100644 index 0000000..a62d6c0 --- /dev/null +++ b/libs/event-emitter/src/calendar/calendar.controller.ts @@ -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 { + return await this.calendarService.getICalData(filter); + } + + @Get('/simple') + async simple( + @Query('project') project?: string, + @Query('category') category?: string + ): Promise { + 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); + } +} \ No newline at end of file diff --git a/libs/event-emitter/src/calendar/calendar.service.ts b/libs/event-emitter/src/calendar/calendar.service.ts new file mode 100644 index 0000000..7f0833c --- /dev/null +++ b/libs/event-emitter/src/calendar/calendar.service.ts @@ -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 { + 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`; + } +} \ No newline at end of file diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index d24ab6a..7383ec6 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -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 { IssuesUpdaterService } from './issues-updater/issues-updater.service'; import { CalendarEnhancer } from './issue-enhancers/calendar-enhancer'; +import { CalendarService } from './calendar/calendar.service'; +import { CalendarController } from './calendar/calendar.controller'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -83,6 +85,14 @@ export class EventEmitterModule implements OnModuleInit { }, 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: [ EventEmitterService, @@ -114,8 +124,12 @@ export class EventEmitterModule implements OnModuleInit { provide: 'CALENDAR_ENHANCER', useExisting: 'CALENDAR_ENHANCER', }, + { + provide: 'CALENDAR_SERVICE', + useExisting: 'CALENDAR_SERVICE', + }, ], - controllers: [MainController, UsersController, IssuesController], + controllers: [MainController, UsersController, IssuesController, CalendarController], }; } diff --git a/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts b/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts index bbda56b..92dfc8c 100644 --- a/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts @@ -49,7 +49,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface { try { res[this.calendarEventsKey] = this.getCalendarEvents(res); } 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; } 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 calendarStartIndex = lines.indexOf('Календарь:'); + const calendarStartIndex = lines.indexOf(this.descriptionCalendarParams.title); if (calendarStartIndex < 0) return []; let index = calendarStartIndex + 1; @@ -170,6 +170,7 @@ export class CalendarEnhancer implements IssueEnhancerInterface { } private extractFromList(line: string): string | null { + if (!line || line.length <= 0) return null; const regexp = new RegExp(this.descriptionCalendarParams.lineRegexp); const match = line.match(regexp); return match && match[0] ? match[0] : null; diff --git a/libs/event-emitter/src/utils/string-with-dates-parser.ts b/libs/event-emitter/src/utils/string-with-dates-parser.ts index 55b4b46..b1c39ec 100644 --- a/libs/event-emitter/src/utils/string-with-dates-parser.ts +++ b/libs/event-emitter/src/utils/string-with-dates-parser.ts @@ -7,8 +7,8 @@ export const DEFAULT_PARAMS: Moo.Rules = { WS: /[ \t]+/, delimiter: /(?: - |: )/, delimiter2: /(?:^\* |^- |(?!\d)T(?!>\d))/, - date: /(?:(?:\d{2}\.\d{2}\.(?:\d{2}|\d{4}))|(?:\d{4}-\d{2}-\d{2}))/, - time: /(?:\d{2}\b:\d{2}:\d{2}|\d{2}:\d{2})/, + date: /\b(?:(?:\d{2}\.\d{2}\.(?:\d{2}|\d{4}))|(?:\d{4}-\d{2}-\d{2}))\b/, + time: /\b(?:\d{2}\b:\d{2}:\d{2}|\d{2}:\d{2})\b/, word: /[\wА-Яа-я]+/, other: { match: /./, lineBreaks: true }, NL: { match: /\n/, lineBreaks: true }, @@ -136,6 +136,12 @@ export function parseToCalendarEvent( to = date2.plus({ day: 1 }); } else if (fullDay) { to = from.plus({ day: 1 }); + } else if (time2) { + to = date1.set({ + hour: time2.hours, + minute: time2.minutes, + second: time2.seconds + }); } else { to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION)); }