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:
@@ -52,13 +52,6 @@ export default function ViewEventModal(
|
|||||||
title={calendar.color}
|
title={calendar.color}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{calendarEvent.extra.recurring_rrule
|
|
||||||
? (
|
|
||||||
<section class='py-5 my-0 border-b border-slate-500'>
|
|
||||||
<p>Repeats {convertRRuleToWords(calendarEvent.extra.recurring_rrule).toLowerCase()}.</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
{calendarEvent.extra.description
|
{calendarEvent.extra.description
|
||||||
? (
|
? (
|
||||||
<section class='py-5 my-0 border-b border-slate-500'>
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
@@ -104,11 +97,18 @@ export default function ViewEventModal(
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
{calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p class='text-xs'>Repeats {convertRRuleToWords(calendarEvent.extra.recurring_rrule).toLowerCase()}.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{Array.isArray(calendarEvent.extra.reminders) && calendarEvent.extra.reminders.length > 0
|
{Array.isArray(calendarEvent.extra.reminders) && calendarEvent.extra.reminders.length > 0
|
||||||
? (
|
? (
|
||||||
<section class='py-5 my-0 border-b border-slate-500 text-xs'>
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
{calendarEvent.extra.reminders.map((reminder) => (
|
{calendarEvent.extra.reminders.map((reminder) => (
|
||||||
<p class='my-1'>
|
<p class='my-1 text-xs'>
|
||||||
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.start_date))} via{' '}
|
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.start_date))} via{' '}
|
||||||
{reminder.type}.
|
{reminder.type}.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import Locker from '/lib/interfaces/locker.ts';
|
||||||
import { NewsFeed } from '/lib/types.ts';
|
import { NewsFeed } from '/lib/types.ts';
|
||||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
import { crawlNewsFeed } from '/lib/data/news.ts';
|
import { crawlNewsFeed } from '/lib/data/news.ts';
|
||||||
@@ -8,6 +9,10 @@ const db = new Database();
|
|||||||
export async function fetchNewArticles(forceFetch = false) {
|
export async function fetchNewArticles(forceFetch = false) {
|
||||||
const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4));
|
const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4));
|
||||||
|
|
||||||
|
const lock = new Locker(`feeds`);
|
||||||
|
|
||||||
|
await lock.acquire();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const feedsToCrawl = await db.query<NewsFeed>(
|
const feedsToCrawl = await db.query<NewsFeed>(
|
||||||
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`,
|
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`,
|
||||||
@@ -22,4 +27,6 @@ export async function fetchNewArticles(forceFetch = false) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"/": "./",
|
"/": "./",
|
||||||
"./": "./",
|
"./": "./",
|
||||||
"xml/": "https://deno.land/x/xml@2.1.3/",
|
"xml/": "https://deno.land/x/xml@2.1.3/",
|
||||||
|
"rrule-rust": "npm:rrule-rust@1.2.0",
|
||||||
|
|
||||||
"fresh/": "https://deno.land/x/fresh@1.6.8/",
|
"fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { RRuleSet } from 'rrule-rust';
|
||||||
|
|
||||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
import Locker from '/lib/interfaces/locker.ts';
|
||||||
|
import { Calendar, CalendarEvent, CalendarEventReminder } from '/lib/types.ts';
|
||||||
import { getRandomItem } from '/lib/utils/misc.ts';
|
import { getRandomItem } from '/lib/utils/misc.ts';
|
||||||
import { CALENDAR_COLOR_OPTIONS } from '/lib/utils/calendar.ts';
|
import { CALENDAR_COLOR_OPTIONS, getVCalendarDate } from '/lib/utils/calendar.ts';
|
||||||
import { getUserById } from './user.ts';
|
import { getUserById } from './user.ts';
|
||||||
|
|
||||||
const db = new Database();
|
const db = new Database();
|
||||||
@@ -33,8 +36,129 @@ export async function getCalendarEvents(
|
|||||||
|
|
||||||
return calendarEvents;
|
return calendarEvents;
|
||||||
} else {
|
} else {
|
||||||
|
// Fetch initial recurring events and calculate any necessary to create/show for the date range, if it's not in the past
|
||||||
|
if (dateRange.end >= new Date()) {
|
||||||
|
const lock = new Locker(`events-${userId}`);
|
||||||
|
|
||||||
|
await lock.acquire();
|
||||||
|
|
||||||
|
const initialRecurringCalendarEvents = await db.query<CalendarEvent>(
|
||||||
|
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||||
|
WHERE "user_id" = $1
|
||||||
|
AND "calendar_id" = ANY($2)
|
||||||
|
AND "start_date" <= $3
|
||||||
|
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||||
|
AND ("extra" ->> 'recurring_id')::uuid = "id"
|
||||||
|
ORDER BY "start_date" ASC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
calendarIds,
|
||||||
|
dateRange.end,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// For each initial recurring event, check instance dates, check if those exist in calendarEvents. If not, create them.
|
||||||
|
for (const initialRecurringCalendarEvent of initialRecurringCalendarEvents) {
|
||||||
|
try {
|
||||||
|
const oneMonthAgo = new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 1));
|
||||||
|
|
||||||
|
let recurringInstanceStartDate = initialRecurringCalendarEvent.start_date;
|
||||||
|
let lastSequence = initialRecurringCalendarEvent.extra.recurring_sequence!;
|
||||||
|
|
||||||
|
if (recurringInstanceStartDate <= oneMonthAgo) {
|
||||||
|
// Fetch the latest recurring sample, so we don't have to calculate as many recurring dates, but still preserve the original date's properties for generating the recurring instances
|
||||||
|
const latestRecurringInstance = (await db.query<CalendarEvent>(
|
||||||
|
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||||
|
WHERE "user_id" = $1
|
||||||
|
AND "calendar_id" = ANY($2)
|
||||||
|
AND "start_date" <= $3
|
||||||
|
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||||
|
AND ("extra" ->> 'recurring_id')::uuid = $4
|
||||||
|
ORDER BY ("extra" ->> 'recurring_sequence')::number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
calendarIds,
|
||||||
|
dateRange.end,
|
||||||
|
initialRecurringCalendarEvent.extra.recurring_id!,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (latestRecurringInstance) {
|
||||||
|
recurringInstanceStartDate = latestRecurringInstance.start_date;
|
||||||
|
lastSequence = latestRecurringInstance.extra.recurring_sequence!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rRuleSet = RRuleSet.parse(
|
||||||
|
`DTSTART:${
|
||||||
|
getVCalendarDate(recurringInstanceStartDate)
|
||||||
|
}\n${initialRecurringCalendarEvent.extra.recurring_rrule}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxRecurringDatesToGenerate = 30;
|
||||||
|
|
||||||
|
const timestamps = rRuleSet.all(maxRecurringDatesToGenerate);
|
||||||
|
|
||||||
|
const validDates = timestamps.map((timestamp) => new Date(timestamp)).filter((date) => date <= dateRange.end);
|
||||||
|
|
||||||
|
// For each date, check if an instance already exists. If not, create it and add it.
|
||||||
|
for (const instanceDate of validDates) {
|
||||||
|
instanceDate.setHours(recurringInstanceStartDate.getHours()); // NOTE: Something is making the hour shift when it shouldn't
|
||||||
|
|
||||||
|
const matchingRecurringInstance = (await db.query<CalendarEvent>(
|
||||||
|
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||||
|
WHERE "user_id" = $1
|
||||||
|
AND "calendar_id" = ANY($2)
|
||||||
|
AND "start_date" = $3
|
||||||
|
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||||
|
AND ("extra" ->> 'recurring_id')::uuid = $4
|
||||||
|
ORDER BY "start_date" ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
calendarIds,
|
||||||
|
instanceDate,
|
||||||
|
initialRecurringCalendarEvent.extra.recurring_id!,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!matchingRecurringInstance) {
|
||||||
|
const oneHourLater = new Date(new Date(instanceDate).setHours(instanceDate.getHours() + 1));
|
||||||
|
const newCalendarEvent = await createCalendarEvent(
|
||||||
|
userId,
|
||||||
|
initialRecurringCalendarEvent.calendar_id,
|
||||||
|
initialRecurringCalendarEvent.title,
|
||||||
|
instanceDate,
|
||||||
|
oneHourLater,
|
||||||
|
initialRecurringCalendarEvent.is_all_day,
|
||||||
|
);
|
||||||
|
|
||||||
|
newCalendarEvent.extra = { ...newCalendarEvent.extra, ...initialRecurringCalendarEvent.extra };
|
||||||
|
|
||||||
|
newCalendarEvent.extra.recurring_sequence = ++lastSequence;
|
||||||
|
|
||||||
|
await updateCalendarEvent(newCalendarEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating recurring instances: ${error}`);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
|
||||||
const calendarEvents = await db.query<CalendarEvent>(
|
const calendarEvents = await db.query<CalendarEvent>(
|
||||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) AND (("start_date" >= $3 OR "end_date" <= $4) OR ("start_date" < $3 AND "end_date" > $4)) ORDER BY "start_date" ASC`,
|
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||||
|
WHERE "user_id" = $1
|
||||||
|
AND "calendar_id" = ANY($2)
|
||||||
|
AND (
|
||||||
|
("start_date" >= $3 OR "end_date" <= $4)
|
||||||
|
OR ("start_date" < $3 AND "end_date" > $4)
|
||||||
|
)
|
||||||
|
ORDER BY "start_date" ASC`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
calendarIds,
|
calendarIds,
|
||||||
@@ -43,8 +167,6 @@ export async function getCalendarEvents(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Fetch initial recurring events and calculate any necessary to create/show for the date range
|
|
||||||
|
|
||||||
return calendarEvents;
|
return calendarEvents;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,9 +305,18 @@ export async function createCalendarEvent(
|
|||||||
throw new Error('Calendar not found');
|
throw new Error('Calendar not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oneHourEarlier = new Date(new Date(startDate).setHours(new Date(startDate).getHours() - 1));
|
||||||
|
const sameDayAtNine = new Date(new Date(startDate).setHours(9));
|
||||||
|
|
||||||
|
const newReminder: CalendarEventReminder = {
|
||||||
|
start_date: isAllDay ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(),
|
||||||
|
type: 'display',
|
||||||
|
};
|
||||||
|
|
||||||
const extra: CalendarEvent['extra'] = {
|
const extra: CalendarEvent['extra'] = {
|
||||||
organizer_email: user.email,
|
organizer_email: user.email,
|
||||||
transparency: 'default',
|
transparency: 'default',
|
||||||
|
reminders: [newReminder],
|
||||||
};
|
};
|
||||||
|
|
||||||
const revision = crypto.randomUUID();
|
const revision = crypto.randomUUID();
|
||||||
@@ -240,6 +371,30 @@ export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalen
|
|||||||
|
|
||||||
const oldCalendar = oldCalendarId ? await getCalendar(oldCalendarId, user.id) : null;
|
const oldCalendar = oldCalendarId ? await getCalendar(oldCalendarId, user.id) : null;
|
||||||
|
|
||||||
|
const oldCalendarEvent = await getCalendarEvent(calendarEvent.id, user.id);
|
||||||
|
|
||||||
|
if (oldCalendarEvent.start_date !== calendarEvent.start_date) {
|
||||||
|
const oneHourEarlier = new Date(
|
||||||
|
new Date(calendarEvent.start_date).setHours(new Date(calendarEvent.start_date).getHours() - 1),
|
||||||
|
);
|
||||||
|
const sameDayAtNine = new Date(new Date(calendarEvent.start_date).setHours(9));
|
||||||
|
|
||||||
|
const newReminder: CalendarEventReminder = {
|
||||||
|
start_date: calendarEvent.is_all_day ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(),
|
||||||
|
type: 'display',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(calendarEvent.extra.reminders)) {
|
||||||
|
calendarEvent.extra.reminders = [newReminder];
|
||||||
|
} else {
|
||||||
|
if (calendarEvent.extra.reminders.length === 0) {
|
||||||
|
calendarEvent.extra.reminders.push(newReminder);
|
||||||
|
} else {
|
||||||
|
calendarEvent.extra.reminders[0] = { ...calendarEvent.extra.reminders[0], start_date: newReminder.start_date };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
sql`UPDATE "bewcloud_calendar_events" SET
|
sql`UPDATE "bewcloud_calendar_events" SET
|
||||||
"revision" = $3,
|
"revision" = $3,
|
||||||
|
|||||||
36
lib/interfaces/locker.ts
Normal file
36
lib/interfaces/locker.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// This is a simple, in-memory lock, which works well enough if you're serving from a single source/server
|
||||||
|
|
||||||
|
function wait(milliseconds = 100) {
|
||||||
|
return new Promise((resolve) => setTimeout(() => resolve(true), milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationInMilliseconds = 15 * 1000;
|
||||||
|
|
||||||
|
const locks: Map<string, { expiresAt: Date }> = new Map();
|
||||||
|
|
||||||
|
export default class Locker {
|
||||||
|
protected lockName: string = '';
|
||||||
|
|
||||||
|
constructor(lockName: string) {
|
||||||
|
this.lockName = lockName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async acquire() {
|
||||||
|
const currentLock = locks.get(this.lockName);
|
||||||
|
|
||||||
|
while (currentLock) {
|
||||||
|
// Only wait if the lock hasn't expired
|
||||||
|
if (currentLock.expiresAt > new Date()) {
|
||||||
|
await wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locks.set(this.lockName, {
|
||||||
|
expiresAt: new Date(new Date().setMilliseconds(new Date().getMilliseconds() + expirationInMilliseconds)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public release() {
|
||||||
|
locks.delete(this.lockName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,18 @@ function getAttendeeStatusFromVCalendar(
|
|||||||
return 'invited';
|
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
|
// TODO: Build this
|
||||||
export function formatCalendarEventsToVCalendar(
|
export function formatCalendarEventsToVCalendar(
|
||||||
calendarEvents: CalendarEvent[],
|
calendarEvents: CalendarEvent[],
|
||||||
@@ -72,23 +84,25 @@ export function formatCalendarEventsToVCalendar(
|
|||||||
): string {
|
): string {
|
||||||
const vCalendarText = calendarEvents.map((calendarEvent) =>
|
const vCalendarText = calendarEvents.map((calendarEvent) =>
|
||||||
`BEGIN:VEVENT
|
`BEGIN:VEVENT
|
||||||
DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
|
DTSTAMP:${getVCalendarDate(calendarEvent.created_at)}
|
||||||
DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
|
DTSTART:${getVCalendarDate(calendarEvent.start_date)}
|
||||||
DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
|
DTEND:${getVCalendarDate(calendarEvent.end_date)}
|
||||||
ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email}
|
ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email}
|
||||||
SUMMARY:${calendarEvent.title.replaceAll('\n', '\\n').replaceAll(',', '\\,')}
|
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
|
||||||
TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()}
|
TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()}
|
||||||
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
|
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
|
||||||
${calendarEvent.extra.recurring_rrule ? `RRULE:${calendarEvent.extra.recurring_rrule}` : ''}
|
${
|
||||||
SEQUENCE:${calendarEvent.extra.recurring_sequence || 0}
|
calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
|
||||||
CREATED:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')}
|
? `RRULE:${calendarEvent.extra.recurring_rrule}`
|
||||||
LAST-MODIFIED:${
|
: ''
|
||||||
new Date(calendarEvent.updated_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
|
|
||||||
}
|
}
|
||||||
|
SEQUENCE:${calendarEvent.extra.recurring_sequence || 0}
|
||||||
|
CREATED:${getVCalendarDate(calendarEvent.created_at)}
|
||||||
|
LAST-MODIFIED:${getVCalendarDate(calendarEvent.updated_at)}
|
||||||
${
|
${
|
||||||
calendarEvent.extra.attendees?.map((attendee) =>
|
calendarEvent.extra.attendees?.map((attendee) =>
|
||||||
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
|
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
|
||||||
attendee.name?.replaceAll('\n', '\\n').replaceAll(',', '\\,') || ''
|
getSafelyEscapedTextForVCalendar(attendee.name || '')
|
||||||
}:mailto:${attendee.email}`
|
}:mailto:${attendee.email}`
|
||||||
).join('\n') || ''
|
).join('\n') || ''
|
||||||
}
|
}
|
||||||
@@ -96,18 +110,10 @@ ${
|
|||||||
calendarEvent.extra.reminders?.map((reminder) =>
|
calendarEvent.extra.reminders?.map((reminder) =>
|
||||||
`BEGIN:VALARM
|
`BEGIN:VALARM
|
||||||
ACTION:${reminder.type.toUpperCase()}
|
ACTION:${reminder.type.toUpperCase()}
|
||||||
${reminder.description ? `DESCRIPTION:${reminder.description.replaceAll('\n', '\\n').replaceAll(',', '\\,')}` : ''}
|
${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''}
|
||||||
TRIGGER;VALUE=DATE-TIME:${
|
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)}
|
||||||
new Date(reminder.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
|
|
||||||
}
|
|
||||||
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
||||||
${
|
${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''}
|
||||||
reminder.acknowledged_at
|
|
||||||
? `ACKNOWLEDGED:${
|
|
||||||
new Date(reminder.acknowledged_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')
|
|
||||||
}`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
END:VALARM`
|
END:VALARM`
|
||||||
).join('\n') || ''
|
).join('\n') || ''
|
||||||
}
|
}
|
||||||
@@ -222,7 +228,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('DESCRIPTION:')) {
|
if (line.startsWith('DESCRIPTION:')) {
|
||||||
const description = line.replace('DESCRIPTION:', '').trim().replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
|
||||||
|
|
||||||
if (!description) {
|
if (!description) {
|
||||||
continue;
|
continue;
|
||||||
@@ -243,7 +249,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('SUMMARY:')) {
|
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;
|
partialCalendarEvent.title = title;
|
||||||
|
|
||||||
@@ -322,7 +328,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
|
|||||||
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
||||||
);
|
);
|
||||||
const attendeeNameInfo = line.split('CN=')[1] || '';
|
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) {
|
if (!attendeeEmail) {
|
||||||
continue;
|
continue;
|
||||||
@@ -420,6 +426,7 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
|
|||||||
|
|
||||||
partialCalendarEvent.extra = {
|
partialCalendarEvent.extra = {
|
||||||
...(partialCalendarEvent.extra! || {}),
|
...(partialCalendarEvent.extra! || {}),
|
||||||
|
is_recurring: true,
|
||||||
recurring_rrule: rRule,
|
recurring_rrule: rRule,
|
||||||
recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0,
|
recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0,
|
||||||
};
|
};
|
||||||
@@ -489,7 +496,7 @@ export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean
|
|||||||
export function getDaysForWeek(
|
export function getDaysForWeek(
|
||||||
date: Date,
|
date: Date,
|
||||||
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
|
): { 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 currentHour = new Date().getHours();
|
||||||
|
|
||||||
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { Contact, ContactAddress, ContactField } from '../types.ts';
|
|||||||
|
|
||||||
export const CONTACTS_PER_PAGE_COUNT = 20;
|
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 {
|
export function formatContactToVCard(contacts: Contact[]): string {
|
||||||
const vCardText = contacts.map((contact) =>
|
const vCardText = contacts.map((contact) =>
|
||||||
`BEGIN:VCARD
|
`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.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : ''
|
||||||
};${contact.extra.name_title || ''};
|
};${contact.extra.name_title || ''};
|
||||||
FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name}
|
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.organization ? `ORG:${getSafelyEscapedTextForVCard(contact.extra.organization)}` : ''}
|
||||||
${contact.extra.role ? `TITLE:${contact.extra.role}` : ''}
|
${contact.extra.role ? `TITLE:${getSafelyEscapedTextForVCard(contact.extra.role)}` : ''}
|
||||||
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
|
${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.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''}
|
||||||
${
|
${
|
||||||
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
|
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
|
||||||
@@ -22,13 +30,11 @@ ${
|
|||||||
}
|
}
|
||||||
${
|
${
|
||||||
contact.extra.addresses?.map((address) =>
|
contact.extra.addresses?.map((address) =>
|
||||||
`ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
`ADR;TYPE=${address.label}:${getSafelyEscapedTextForVCard(address.line_2 || '')};${
|
||||||
(address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
getSafelyEscapedTextForVCard(address.line_1 || '')
|
||||||
};${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
};${getSafelyEscapedTextForVCard(address.city || '')};${getSafelyEscapedTextForVCard(address.state || '')};${
|
||||||
(address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
getSafelyEscapedTextForVCard(address.postal_code || '')
|
||||||
};${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
};${getSafelyEscapedTextForVCard(address.country || '')}`
|
||||||
(address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
|
||||||
}`
|
|
||||||
).join('\n') || ''
|
).join('\n') || ''
|
||||||
}
|
}
|
||||||
${
|
${
|
||||||
@@ -41,11 +47,7 @@ ${
|
|||||||
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
||||||
.join('\n') || ''
|
.join('\n') || ''
|
||||||
}
|
}
|
||||||
${
|
${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''}
|
||||||
contact.extra.notes
|
|
||||||
? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
||||||
END:VCARD`
|
END:VCARD`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
@@ -191,7 +193,7 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('NOTE:')) {
|
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 = {
|
||||||
...(partialContact.extra || {}),
|
...(partialContact.extra || {}),
|
||||||
@@ -204,35 +206,16 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
|||||||
if (line.includes('ADR;')) {
|
if (line.includes('ADR;')) {
|
||||||
const addressInfo = line.split('ADR;')[1] || '';
|
const addressInfo = line.split('ADR;')[1] || '';
|
||||||
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
||||||
const country = addressParts.slice(-1, addressParts.length).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 postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll(
|
const line1 = getSafelyUnescapedTextFromVCard(addressParts.slice(-5, addressParts.length - 4).join(' '));
|
||||||
'\\,',
|
const line2 = getSafelyUnescapedTextFromVCard(addressParts.slice(-6, addressParts.length - 5).join(' '));
|
||||||
',',
|
|
||||||
);
|
|
||||||
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 label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll(
|
const label = getSafelyUnescapedTextFromVCard(
|
||||||
'\\n',
|
((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''),
|
||||||
'\n',
|
);
|
||||||
).replaceAll('\\,', ',');
|
|
||||||
|
|
||||||
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
if (parsedExtra !== '{}') {
|
if (parsedExtra !== '{}') {
|
||||||
calendarEvent.extra = partialCalendarEvent.extra!;
|
calendarEvent.extra = partialCalendarEvent.extra!;
|
||||||
|
|
||||||
|
if (
|
||||||
|
calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_sequence === 0 &&
|
||||||
|
!calendarEvent.extra.recurring_id
|
||||||
|
) {
|
||||||
|
calendarEvent.extra.recurring_id = calendarEvent.id;
|
||||||
|
}
|
||||||
|
|
||||||
await updateCalendarEvent(calendarEvent);
|
await updateCalendarEvent(calendarEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds);
|
const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds, dateRange);
|
||||||
|
|
||||||
return await context.render({ userCalendars, userCalendarEvents, view, startDate });
|
return await context.render({ userCalendars, userCalendarEvents, view, startDate });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Handler } from 'fresh/server.ts';
|
|||||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||||
import { getCalendar, getCalendarEvents } from '/lib/data/calendar.ts';
|
import {
|
||||||
|
createCalendarEvent,
|
||||||
|
getCalendar,
|
||||||
|
getCalendarEvent,
|
||||||
|
getCalendarEvents,
|
||||||
|
updateCalendarEvent,
|
||||||
|
} from '/lib/data/calendar.ts';
|
||||||
import { createSessionCookie } from '/lib/auth.ts';
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
interface Data {}
|
interface Data {}
|
||||||
@@ -80,32 +86,37 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
|
|
||||||
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||||
|
|
||||||
if (partialCalendarEvent.title) {
|
if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) {
|
||||||
// TODO: Build this
|
const newCalendarEvent = await createCalendarEvent(
|
||||||
// const newCalendarEvent = await createCalendarEvent(
|
context.state.user.id,
|
||||||
// context.state.user.id,
|
calendarId,
|
||||||
// partialCalendarEvent.title,
|
partialCalendarEvent.title,
|
||||||
// partialCalendarEvent.start_date,
|
new Date(partialCalendarEvent.start_date),
|
||||||
// partialCalendarEvent.end_date,
|
new Date(partialCalendarEvent.end_date),
|
||||||
// );
|
partialCalendarEvent.is_all_day,
|
||||||
|
);
|
||||||
|
|
||||||
// // Use the sent id for the UID
|
const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
|
||||||
// if (!partialCalendarEvent.extra?.uid) {
|
|
||||||
// partialCalendarEvent.extra = {
|
|
||||||
// ...(partialCalendarEvent.extra! || {}),
|
|
||||||
// uid: calendarId,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// newCalendarEvent.extra = partialCalendarEvent.extra!;
|
if (parsedExtra !== '{}') {
|
||||||
|
newCalendarEvent.extra = partialCalendarEvent.extra!;
|
||||||
|
|
||||||
// await updateCalendarEvent(newCalendarEvent);
|
if (
|
||||||
|
newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 &&
|
||||||
// const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
|
!newCalendarEvent.extra.recurring_id
|
||||||
|
) {
|
||||||
// return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
newCalendarEvent.extra.recurring_id = newCalendarEvent.id;
|
||||||
return new Response('Not found', { status: 404 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateCalendarEvent(newCalendarEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
|
||||||
|
|
||||||
|
return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarEvents = await getCalendarEvents(context.state.user.id, [calendar.id]);
|
const calendarEvents = await getCalendarEvents(context.state.user.id, [calendar.id]);
|
||||||
|
|||||||
Reference in New Issue
Block a user