feat: calendar plugin

This commit is contained in:
Thea Schöbl
2022-01-31 15:57:38 +00:00
committed by Rainer Killinger
parent 080e6fa3e8
commit a57c3029df
54 changed files with 2880 additions and 70 deletions

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {findRRules, RRule} from './ical';
import moment, {unitOfTime} from 'moment';
import {shuffle} from 'lodash-es';
import {SCISO8601Date} from '@openstapps/core';
/**
*
*/
function expandRRule(rule: RRule): SCISO8601Date[] {
const initial = moment(rule.from);
const interval = rule.interval ?? 1;
return shuffle(
Array.from({
length:
Math.floor(
moment(rule.until).diff(initial, rule.freq, true) / interval,
) + 1,
}).map((_, i) =>
initial
.clone()
.add(interval * i, rule.freq ?? 'day')
.toISOString(),
),
);
}
describe('iCal', () => {
it('should find simple recurrence patterns', () => {
for (const freq of ['day', 'week', 'month', 'year'] as unitOfTime.Diff[]) {
for (const interval of [1, 2, 3]) {
const pattern: RRule = {
freq: freq,
interval: interval,
from: moment('2021-09-01T10:00').toISOString(),
until: moment('2021-09-01T10:00')
.add(4 * interval, freq)
.toISOString(),
};
expect(findRRules(expandRRule(pattern))).toEqual([pattern]);
}
}
});
it('should find missing recurrence patterns', () => {
const pattern: SCISO8601Date = moment('2021-09-01T10:00').toISOString();
expect(findRRules([pattern])).toEqual([pattern]);
});
it('should find mixed recurrence patterns', () => {
const singlePattern: SCISO8601Date =
moment('2021-09-01T09:00').toISOString();
const weeklyPattern: RRule = {
freq: 'week',
interval: 1,
from: moment('2021-09-03T10:00').toISOString(),
until: moment('2021-09-03T10:00').add(4, 'weeks').toISOString(),
};
expect(
findRRules(shuffle([singlePattern, ...expandRRule(weeklyPattern)])),
).toEqual([singlePattern, weeklyPattern]);
});
});

View File

@@ -0,0 +1,414 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public Licens for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
SCDateSeries,
SCISO8601Date,
SCISO8601Duration,
SCThingTranslator,
SCThingWithCategories,
SCUuid,
} from '@openstapps/core';
import {
difference,
flatMap,
isObject,
last,
mapValues,
minBy,
size,
} from 'lodash-es';
import moment, {unitOfTime} from 'moment';
export interface ICalEvent {
name?: string;
uuid: SCUuid;
categories?: string[];
description?: string;
cancelled?: boolean;
recurrenceId?: SCISO8601Date;
geo?: string;
/**
* The sequence index if the series had to be split into multiple rrules
*/
recurrenceSequence?: number;
recurrenceSequenceAmount?: number;
rrule?: RRule;
dates?: SCISO8601Date[];
exceptionDates?: SCISO8601Date[];
start: SCISO8601Date;
sequence?: number;
duration?: SCISO8601Duration;
url?: string;
}
export type ICalKeyValuePair = `${Uppercase<string>}${':' | '='}${string}`;
export type ICalLike = ICalKeyValuePair[];
/**
*
*/
function timeDist(
current: SCISO8601Date,
next: SCISO8601Date | undefined,
recurrence: unitOfTime.Diff,
): number | undefined {
if (!next) {
return undefined;
}
const diff = moment(next).diff(moment(current), recurrence, true);
return Math.floor(diff) === diff ? diff : undefined;
}
export interface RRule {
freq: unitOfTime.Diff; // 'SECONDLY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
interval: number;
from: SCISO8601Date;
until: SCISO8601Date;
}
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export interface MergedRRule {
rrule?: RRule;
exceptions?: SCISO8601Date[];
date?: SCISO8601Date;
}
/**
* Merge compatible RRules to a single RRule with exceptions
*/
export function mergeRRules(
rules: Array<RRule | SCISO8601Date>,
allowExceptions = true,
): MergedRRule[] {
if (!allowExceptions)
return rules.map(it => (typeof it === 'string' ? {date: it} : {rrule: it}));
/*map(groupBy(rules, it => `${it.freq}@${it.interval}`), it => {
});*/
return rules.map(it =>
typeof it === 'string' ? {date: it} : {rrule: it},
) /* TODO */;
}
/**
* Find RRules in a list of dates
*/
export function findRRules(
dates: SCISO8601Date[],
): Array<RRule | SCISO8601Date> {
const sorted = dates.sort((a, b) => moment(a).unix() - moment(b).unix());
const output: Optional<RRule, 'freq'>[] = [
{
from: sorted[0],
until: sorted[0],
interval: -1,
},
];
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i];
const next = sorted[i + 1] as SCISO8601Date | undefined;
const element = last(output);
const units: unitOfTime.Diff[] = element?.freq
? [element.freq]
: ['day', 'week', 'month', 'year'];
const freq = minBy(
units.map(recurrence => ({
recurrence: recurrence,
dist: timeDist(current, next, recurrence),
})),
it => it.dist,
)?.recurrence;
const interval = freq ? timeDist(current, next, freq) : undefined;
if (element?.interval === -1) {
element.freq = freq;
element.interval = interval ?? -1;
}
if (!freq || element?.freq !== freq || element.interval !== interval) {
if (element) {
element.until = current;
}
if (next) {
output.push({
from: next,
until: next,
interval: -1,
});
}
} else {
element.until = current;
}
}
return output.map(it => (it.freq ? (it as RRule) : it.from));
}
/**
*
*/
export function strikethrough(text: string): string {
return `\u274C ${[...text].join('\u0336')}\u0336`;
}
/**
*
*/
function getICalData(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
): Pick<ICalEvent, 'name' | 'uuid' | 'categories' | 'description' | 'geo'> {
const translated = translator.translatedAccess(dateSeries);
return {
name: translated.event()?.name,
uuid: dateSeries.uid,
categories: [
'stapps',
...((translated.event() as SCThingWithCategories<string, never>)
?.categories ?? []),
],
description: translated.event()?.description ?? translated.description(),
geo: translated.inPlace()?.name,
};
}
export interface ToICalOptions {
allowRRuleExceptions?: boolean;
excludeCancelledEvents?: boolean;
}
/**
*
*/
export function toICal(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
options: ToICalOptions = {},
): ICalEvent[] {
const rrules = findRRules(
options.excludeCancelledEvents
? difference(dateSeries.dates, dateSeries.exceptions ?? [])
: dateSeries.dates,
);
return mergeRRules(rrules, options.allowRRuleExceptions).map(
(it, i, array) => ({
...getICalData(dateSeries, translator),
dates: dateSeries.dates,
rrule: it.rrule,
recurrenceSequence: array.length > 1 ? i + 1 : undefined,
recurrenceSequenceAmount: array.length > 1 ? array.length : undefined,
exceptionDates: it.exceptions,
start: it.rrule?.from ?? it.date ?? dateSeries.dates[0],
sequence: 0,
duration: dateSeries.duration,
}),
);
}
/**
*
*/
export function toICalUpdates(
dateSeries: SCDateSeries,
translator: SCThingTranslator,
): ICalEvent[] {
return (
dateSeries.exceptions?.map(exception => ({
...getICalData(dateSeries, translator),
sequence: 1,
recurrenceId: exception,
cancelled: true,
start: exception,
})) ?? []
);
}
/**
* Convert an ISO8601 date to a string in the format YYYYMMDDTHHMMSSZ
*/
export function iso8601ToICalDateTime<T extends SCISO8601Date | undefined>(
date: T,
): T extends SCISO8601Date ? string : undefined {
return (
date ? `${moment(date).utc().format('YYYYMMDDTHHmmss')}Z` : undefined
) as never;
}
/**
* Convert an ISO8601 date to a string in the format YYYYMMDD
*/
export function iso8601ToICalDate(date: SCISO8601Date): string {
return `${moment(date).utc().format('YYYYMMDD')}`;
}
/**
* Recursively stringify all linebreaks to \n strings
*/
function stringifyLinebreaks<T extends string | unknown[] | unknown>(
value: T,
): T {
if (typeof value === 'string') {
return value.replace(/\r?\n|\r/g, '\\n') as T;
}
if (Array.isArray(value)) {
return value.map(stringifyLinebreaks) as T;
}
if (isObject(value)) {
return mapValues(value, stringifyLinebreaks) as T;
}
return value;
}
/**
* Sanitize an ICal object to not contain line breaks and convert dates to iCal format
*/
export function normalizeICalDates(iCal: ICalEvent): ICalEvent {
return {
...iCal,
dates: iCal.dates?.filter(it => it !== iCal.start).map(iso8601ToICalDate),
exceptionDates: iCal.exceptionDates?.map(iso8601ToICalDate),
start: iso8601ToICalDateTime(iCal.start),
recurrenceId: iso8601ToICalDateTime(iCal.recurrenceId),
};
}
const REPEAT_FREQUENCIES: Partial<Record<unitOfTime.Diff, string>> = {
day: 'DAILY',
week: 'WEEKLY',
month: 'MONTHLY',
year: 'YEARLY',
};
/**
*
*/
export function serializeICalLike(iCal: ICalLike): string {
return iCal.map(stringifyLinebreaks).join('\r\n');
}
/**
* Removes all strings that are either undefined or end with 'undefined'
*/
function withoutNullishStrings<T extends string>(
array: Array<T | `${string}${undefined}` | undefined>,
): T[] {
return array.filter(it => it && !it.endsWith('undefined')) as T[];
}
/**
*
*/
export function serializeRRule(rrule?: RRule): string | undefined {
return rrule
? `FREQ=${
REPEAT_FREQUENCIES[rrule.freq ?? 's']
};UNTIL=${iso8601ToICalDateTime(rrule.until)};INTERVAL=${rrule.interval}`
: undefined;
}
/**
* Convert an iCal event to a string
*/
export function serializeICalEvent(iCal: ICalEvent): ICalLike {
const normalized = normalizeICalDates(iCal);
return withoutNullishStrings<ICalKeyValuePair>([
'BEGIN:VEVENT',
`DTSTART:${normalized.start}`,
`DURATION:${normalized.duration}`,
`DTSTAMP:${moment().utc().format('YYYYMMDDTHHmmss')}Z`,
`UID:${normalized.uuid}`,
`RECURRENCE-ID:${normalized.recurrenceId}`,
`CATEGORIES:${normalized.categories?.join(',')}`,
`SUMMARY:${normalized.name}`,
`DESCRIPTION:${normalized.description}`,
`STATUS:${normalized.cancelled === true ? 'CANCELLED' : 'CONFIRMED'}`,
`URL:${normalized.url}`,
// `RDATE;VALUE=DATE:${normalized.dates.join(',')}`,
size(normalized.exceptionDates) > 0
? `EXDATE;VALUE=DATE:${normalized.exceptionDates?.join(',')}`
: undefined,
`RRULE:${serializeRRule(normalized.rrule)}`,
'END:VEVENT',
]);
}
/**
* Convert an iCal object to a string
*/
export function serializeICal(iCal: ICalEvent[]): string {
return serializeICalLike([
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//StApps//NONSGML StApps Calendar//EN',
'NAME:StApps',
'X-WR-CALNAME:StApps',
'X-WR-CALDESC:StApps Calendar',
'X-WR-TIMEZONE:Europe/Berlin',
'LOCATION;LANGUAGE=en:Germany',
'CALSCALE:GREGORIAN',
'COLOR:#FF0000',
'METHOD:PUBLISH',
...flatMap(iCal, serializeICalEvent),
'END:VCALENDAR',
]);
}
/**
* Get transform date series for purpose of native calendar export
*/
export function getNativeCalendarExport(
dateSeries: SCDateSeries[],
translator: SCThingTranslator,
): ICalEvent[] {
return flatMap(dateSeries, event =>
toICal(event, translator, {
allowRRuleExceptions: false,
excludeCancelledEvents: true,
}),
);
}
/**
* Get transform date series for purpose of iCal file export
*/
export function getICalExport(
dateSeries: SCDateSeries[],
translator: SCThingTranslator,
includeCancelled: boolean,
): ICalEvent[] {
return [
...flatMap(dateSeries, event =>
toICal(event, translator, {
allowRRuleExceptions: false,
excludeCancelledEvents: !includeCancelled,
}),
),
...(includeCancelled
? flatMap(dateSeries, event => toICalUpdates(event, translator))
: []),
];
}