Support automatically creating recurring events

Also implement locks for tasks that can have unintended side-effects if ran simultaneously.

Other minor UI tweaks.
This commit is contained in:
Bruno Bernardino
2024-03-31 13:19:13 +01:00
parent d3a6f60d81
commit 321341a2fb
10 changed files with 312 additions and 105 deletions

View File

@@ -65,6 +65,18 @@ function getAttendeeStatusFromVCalendar(
return 'invited';
}
export function getVCalendarDate(date: Date | string) {
return new Date(date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
}
function getSafelyEscapedTextForVCalendar(text: string) {
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
}
function getSafelyUnescapedTextFromVCalendar(text: string) {
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
}
// TODO: Build this
export function formatCalendarEventsToVCalendar(
calendarEvents: CalendarEvent[],
@@ -72,23 +84,25 @@ export function formatCalendarEventsToVCalendar(
): string {
const vCalendarText = calendarEvents.map((calendarEvent) =>
`BEGIN:VEVENT
DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
DTSTAMP:${getVCalendarDate(calendarEvent.created_at)}
DTSTART:${getVCalendarDate(calendarEvent.start_date)}
DTEND:${getVCalendarDate(calendarEvent.end_date)}
ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email}
SUMMARY:${calendarEvent.title.replaceAll('\n', '\\n').replaceAll(',', '\\,')}
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()}
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
${calendarEvent.extra.recurring_rrule ? `RRULE:${calendarEvent.extra.recurring_rrule}` : ''}
SEQUENCE:${calendarEvent.extra.recurring_sequence || 0}
CREATED:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
LAST-MODIFIED:${
new Date(calendarEvent.updated_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
${
calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
? `RRULE:${calendarEvent.extra.recurring_rrule}`
: ''
}
SEQUENCE:${calendarEvent.extra.recurring_sequence || 0}
CREATED:${getVCalendarDate(calendarEvent.created_at)}
LAST-MODIFIED:${getVCalendarDate(calendarEvent.updated_at)}
${
calendarEvent.extra.attendees?.map((attendee) =>
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
attendee.name?.replaceAll('\n', '\\n').replaceAll(',', '\\,') || ''
getSafelyEscapedTextForVCalendar(attendee.name || '')
}:mailto:${attendee.email}`
).join('\n') || ''
}
@@ -96,18 +110,10 @@ ${
calendarEvent.extra.reminders?.map((reminder) =>
`BEGIN:VALARM
ACTION:${reminder.type.toUpperCase()}
${reminder.description ? `DESCRIPTION:${reminder.description.replaceAll('\n', '\\n').replaceAll(',', '\\,')}` : ''}
TRIGGER;VALUE=DATE-TIME:${
new Date(reminder.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
}
${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''}
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)}
${reminder.uid ? `UID:${reminder.uid}` : ''}
${
reminder.acknowledged_at
? `ACKNOWLEDGED:${
new Date(reminder.acknowledged_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
}`
: ''
}
${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''}
END:VALARM`
).join('\n') || ''
}
@@ -222,7 +228,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
}
if (line.startsWith('DESCRIPTION:')) {
const description = line.replace('DESCRIPTION:', '').trim().replaceAll('\\n', '\n').replaceAll('\\,', ',');
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
if (!description) {
continue;
@@ -243,7 +249,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
}
if (line.startsWith('SUMMARY:')) {
const title = (line.split('SUMMARY:')[1] || '').trim().replaceAll('\\n', '\n').replaceAll('\\,', ',');
const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim());
partialCalendarEvent.title = title;
@@ -322,7 +328,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
);
const attendeeNameInfo = line.split('CN=')[1] || '';
const attendeeName = (attendeeNameInfo.split(';')[0] || '').trim().replaceAll('\\n', '\n').replaceAll('\\,', ',');
const attendeeName = getSafelyUnescapedTextFromVCalendar((attendeeNameInfo.split(';')[0] || '').trim());
if (!attendeeEmail) {
continue;
@@ -420,6 +426,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
partialCalendarEvent.extra = {
...(partialCalendarEvent.extra! || {}),
is_recurring: true,
recurring_rrule: rRule,
recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0,
};
@@ -489,7 +496,7 @@ export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean
export function getDaysForWeek(
date: Date,
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
const shortIsoDate = date.toISOString().substring(0, 10);
const shortIsoDate = new Date().toISOString().substring(0, 10);
const currentHour = new Date().getHours();
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];

View File

@@ -2,6 +2,14 @@ import { Contact, ContactAddress, ContactField } from '../types.ts';
export const CONTACTS_PER_PAGE_COUNT = 20;
function getSafelyEscapedTextForVCard(text: string) {
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
}
function getSafelyUnescapedTextFromVCard(text: string) {
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
}
export function formatContactToVCard(contacts: Contact[]): string {
const vCardText = contacts.map((contact) =>
`BEGIN:VCARD
@@ -10,10 +18,10 @@ N:${contact.last_name};${contact.first_name};${
contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : ''
};${contact.extra.name_title || ''};
FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name}
${contact.extra.organization ? `ORG:${contact.extra.organization.replaceAll(',', '\\,')}` : ''}
${contact.extra.role ? `TITLE:${contact.extra.role}` : ''}
${contact.extra.organization ? `ORG:${getSafelyEscapedTextForVCard(contact.extra.organization)}` : ''}
${contact.extra.role ? `TITLE:${getSafelyEscapedTextForVCard(contact.extra.role)}` : ''}
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
${contact.extra.nickname ? `NICKNAME:${contact.extra.nickname}` : ''}
${contact.extra.nickname ? `NICKNAME:${getSafelyEscapedTextForVCard(contact.extra.nickname)}` : ''}
${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''}
${
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
@@ -22,13 +30,11 @@ ${
}
${
contact.extra.addresses?.map((address) =>
`ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
(address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
};${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
(address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
};${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
(address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
}`
`ADR;TYPE=${address.label}:${getSafelyEscapedTextForVCard(address.line_2 || '')};${
getSafelyEscapedTextForVCard(address.line_1 || '')
};${getSafelyEscapedTextForVCard(address.city || '')};${getSafelyEscapedTextForVCard(address.state || '')};${
getSafelyEscapedTextForVCard(address.postal_code || '')
};${getSafelyEscapedTextForVCard(address.country || '')}`
).join('\n') || ''
}
${
@@ -41,11 +47,7 @@ ${
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
.join('\n') || ''
}
${
contact.extra.notes
? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}`
: ''
}
${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''}
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
END:VCARD`
).join('\n');
@@ -191,7 +193,7 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
}
if (line.startsWith('NOTE:')) {
const notes = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ',');
const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || '');
partialContact.extra = {
...(partialContact.extra || {}),
@@ -204,35 +206,16 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
if (line.includes('ADR;')) {
const addressInfo = line.split('ADR;')[1] || '';
const addressParts = (addressInfo.split(':')[1] || '').split(';');
const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll(
'\\,',
',',
);
const country = getSafelyUnescapedTextFromVCard(addressParts.slice(-1, addressParts.length).join(' '));
const postalCode = getSafelyUnescapedTextFromVCard(addressParts.slice(-2, addressParts.length - 1).join(' '));
const state = getSafelyUnescapedTextFromVCard(addressParts.slice(-3, addressParts.length - 2).join(' '));
const city = getSafelyUnescapedTextFromVCard(addressParts.slice(-4, addressParts.length - 3).join(' '));
const line1 = getSafelyUnescapedTextFromVCard(addressParts.slice(-5, addressParts.length - 4).join(' '));
const line2 = getSafelyUnescapedTextFromVCard(addressParts.slice(-6, addressParts.length - 5).join(' '));
const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll(
'\\n',
'\n',
).replaceAll('\\,', ',');
const label = getSafelyUnescapedTextFromVCard(
((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''),
);
if (!country && !postalCode && !state && !city && !line2 && !line1) {
continue;