Files CRUD.
Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
@@ -13,3 +13,11 @@ export async function isSignupAllowed() {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getFilesRootPath() {
|
||||
const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH');
|
||||
|
||||
const filesRootPath = `${Deno.cwd()}/${configRootPath}`;
|
||||
|
||||
return filesRootPath;
|
||||
}
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
import { RRuleSet } from 'rrule-rust';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { Calendar, CalendarEvent, CalendarEventReminder } from '/lib/types.ts';
|
||||
import { getRandomItem } from '/lib/utils/misc.ts';
|
||||
import { CALENDAR_COLOR_OPTIONS, getVCalendarDate } from '/lib/utils/calendar.ts';
|
||||
import { getUserById } from './user.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getCalendars(userId: string): Promise<Calendar[]> {
|
||||
const calendars = await db.query<Calendar>(
|
||||
sql`SELECT * FROM "bewcloud_calendars" WHERE "user_id" = $1 ORDER BY "created_at" ASC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendars;
|
||||
}
|
||||
|
||||
export async function getCalendarEvents(
|
||||
userId: string,
|
||||
calendarIds: string[],
|
||||
dateRange?: { start: Date; end: Date },
|
||||
): Promise<CalendarEvent[]> {
|
||||
if (!dateRange) {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
} 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>(
|
||||
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,
|
||||
calendarIds,
|
||||
dateRange.start,
|
||||
dateRange.end,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCalendarEvent(id: string, userId: string): Promise<CalendarEvent> {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents[0];
|
||||
}
|
||||
|
||||
export async function getCalendar(id: string, userId: string) {
|
||||
const calendars = await db.query<Calendar>(
|
||||
sql`SELECT * FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendars[0];
|
||||
}
|
||||
|
||||
export async function createCalendar(userId: string, name: string, color?: string) {
|
||||
const extra: Calendar['extra'] = {
|
||||
default_transparency: 'opaque',
|
||||
};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const newColor = color || getRandomItem(CALENDAR_COLOR_OPTIONS);
|
||||
|
||||
const newCalendar = (await db.query<Calendar>(
|
||||
sql`INSERT INTO "bewcloud_calendars" (
|
||||
"user_id",
|
||||
"revision",
|
||||
"name",
|
||||
"color",
|
||||
"is_visible",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
revision,
|
||||
name,
|
||||
newColor,
|
||||
true,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newCalendar;
|
||||
}
|
||||
|
||||
export async function updateCalendar(calendar: Calendar) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_calendars" SET
|
||||
"revision" = $3,
|
||||
"name" = $4,
|
||||
"color" = $5,
|
||||
"is_visible" = $6,
|
||||
"extra" = $7,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendar.id,
|
||||
calendar.revision,
|
||||
revision,
|
||||
calendar.name,
|
||||
calendar.color,
|
||||
calendar.is_visible,
|
||||
JSON.stringify(calendar.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteCalendar(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendar_events" WHERE "calendar_id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function updateCalendarRevision(calendar: Calendar) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_calendars" SET
|
||||
"revision" = $3,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendar.id,
|
||||
calendar.revision,
|
||||
revision,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCalendarEvent(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
title: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
isAllDay = false,
|
||||
) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(calendarId, userId);
|
||||
|
||||
if (!calendar) {
|
||||
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'] = {
|
||||
organizer_email: user.email,
|
||||
transparency: 'default',
|
||||
reminders: [newReminder],
|
||||
};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const status: CalendarEvent['status'] = 'scheduled';
|
||||
|
||||
const newCalendarEvent = (await db.query<CalendarEvent>(
|
||||
sql`INSERT INTO "bewcloud_calendar_events" (
|
||||
"user_id",
|
||||
"calendar_id",
|
||||
"revision",
|
||||
"title",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"is_all_day",
|
||||
"status",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
calendarId,
|
||||
revision,
|
||||
title,
|
||||
startDate,
|
||||
endDate,
|
||||
isAllDay,
|
||||
status,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
|
||||
return newCalendarEvent;
|
||||
}
|
||||
|
||||
export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalendarId?: string) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const user = await getUserById(calendarEvent.user_id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(calendarEvent.calendar_id, user.id);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
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(
|
||||
sql`UPDATE "bewcloud_calendar_events" SET
|
||||
"revision" = $3,
|
||||
"calendar_id" = $4,
|
||||
"title" = $5,
|
||||
"start_date" = $6,
|
||||
"end_date" = $7,
|
||||
"is_all_day" = $8,
|
||||
"status" = $9,
|
||||
"extra" = $10,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendarEvent.id,
|
||||
calendarEvent.revision,
|
||||
revision,
|
||||
calendarEvent.calendar_id,
|
||||
calendarEvent.title,
|
||||
calendarEvent.start_date,
|
||||
calendarEvent.end_date,
|
||||
calendarEvent.is_all_day,
|
||||
calendarEvent.status,
|
||||
JSON.stringify(calendarEvent.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
|
||||
if (oldCalendar) {
|
||||
await updateCalendarRevision(oldCalendar);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCalendarEvent(id: string, calendarId: string, userId: string) {
|
||||
const calendar = await getCalendar(calendarId, userId);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3`,
|
||||
[
|
||||
id,
|
||||
calendarId,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
}
|
||||
|
||||
export async function searchCalendarEvents(
|
||||
searchTerm: string,
|
||||
userId: string,
|
||||
calendarIds: string[],
|
||||
): Promise<CalendarEvent[]> {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) AND ("title" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
`%${searchTerm.split(' ').join('%')}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils/contacts.ts';
|
||||
import { updateUserContactRevision } from './user.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getContacts(userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContactsCount(userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function searchContacts(searchTerm: string, userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
`%${searchTerm.split(' ').join('%')}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function searchContactsCount(search: string, userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`,
|
||||
[
|
||||
userId,
|
||||
`%${search}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function getAllContacts(userId: string) {
|
||||
const contacts = await db.query<Contact>(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContact(id: string, userId: string) {
|
||||
const contacts = await db.query<Contact>(
|
||||
sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts[0];
|
||||
}
|
||||
|
||||
export async function createContact(userId: string, firstName: string, lastName: string) {
|
||||
const extra: Contact['extra'] = {};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const newContact = (await db.query<Contact>(
|
||||
sql`INSERT INTO "bewcloud_contacts" (
|
||||
"user_id",
|
||||
"revision",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
revision,
|
||||
firstName,
|
||||
lastName,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
|
||||
return newContact;
|
||||
}
|
||||
|
||||
export async function updateContact(contact: Contact) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_contacts" SET
|
||||
"revision" = $3,
|
||||
"first_name" = $4,
|
||||
"last_name" = $5,
|
||||
"extra" = $6,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
contact.id,
|
||||
contact.revision,
|
||||
revision,
|
||||
contact.first_name,
|
||||
contact.last_name,
|
||||
JSON.stringify(contact.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(contact.user_id);
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
}
|
||||
261
lib/data/files.ts
Normal file
261
lib/data/files.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { join } from 'std/path/join.ts';
|
||||
|
||||
// import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { getFilesRootPath } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
||||
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
|
||||
// const db = new Database();
|
||||
|
||||
export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// const directoryShares = await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares"
|
||||
// WHERE "parent_path" = $2
|
||||
// AND "type" = 'directory'
|
||||
// AND (
|
||||
// "owner_user_id" = $1
|
||||
// OR ANY("user_ids_with_read_access") = $1
|
||||
// OR ANY("user_ids_with_write_access") = $1
|
||||
// )`, [
|
||||
// userId,
|
||||
// path,
|
||||
// ]);
|
||||
|
||||
const directoryShares: FileShare[] = [];
|
||||
|
||||
// TODO: Remove this mock test
|
||||
if (path === '/') {
|
||||
directoryShares.push({
|
||||
id: 'test-ing-123',
|
||||
owner_user_id: userId,
|
||||
parent_path: '/',
|
||||
name: 'Testing',
|
||||
type: 'directory',
|
||||
user_ids_with_read_access: [],
|
||||
user_ids_with_write_access: [],
|
||||
extra: {
|
||||
read_share_links: [],
|
||||
write_share_links: [],
|
||||
},
|
||||
updated_at: new Date('2024-04-01'),
|
||||
created_at: new Date('2024-03-31'),
|
||||
});
|
||||
}
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory);
|
||||
|
||||
for (const entry of directoryEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const directory: Directory = {
|
||||
owner_user_id: userId,
|
||||
parent_path: path,
|
||||
directory_name: entry.name,
|
||||
has_write_access: true,
|
||||
file_share: directoryShares.find((share) =>
|
||||
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
|
||||
),
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
// TODO: Add directoryShares that aren't owned by this user
|
||||
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// const fileShares = await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares"
|
||||
// WHERE "parent_path" = $2
|
||||
// AND "type" = 'file'
|
||||
// AND (
|
||||
// "owner_user_id" = $1
|
||||
// OR ANY("user_ids_with_read_access") = $1
|
||||
// OR ANY("user_ids_with_write_access") = $1
|
||||
// )`, [
|
||||
// userId,
|
||||
// path,
|
||||
// ]);
|
||||
|
||||
const fileShares: FileShare[] = [];
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const file: DirectoryFile = {
|
||||
owner_user_id: userId,
|
||||
parent_path: path,
|
||||
file_name: entry.name,
|
||||
has_write_access: true,
|
||||
file_share: fileShares.find((share) =>
|
||||
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
|
||||
),
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
// TODO: Add fileShares that aren't owned by this user
|
||||
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// Ensure the user directory exists
|
||||
if (path === '/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if (error.toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Deno.DirEntry[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(rootPath)) {
|
||||
entries.push(dirEntry);
|
||||
}
|
||||
|
||||
entries.sort(sortEntriesByName);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.mkdir(join(rootPath, name), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function renameDirectoryOrFile(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
const oldRootPath = join(getFilesRootPath(), userId, oldPath);
|
||||
const newRootPath = join(getFilesRootPath(), userId, newPath);
|
||||
|
||||
try {
|
||||
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName));
|
||||
|
||||
// TODO: Update any matching file shares
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
if (path.startsWith(TRASH_PATH)) {
|
||||
await Deno.remove(join(rootPath, name), { recursive: true });
|
||||
} else {
|
||||
const trashPath = join(getFilesRootPath(), userId, TRASH_PATH);
|
||||
await Deno.rename(join(rootPath, name), join(trashPath, name));
|
||||
|
||||
// TODO: Delete any matching file shares
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string | ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
const rootPath = `${getFilesRootPath()}/${userId}${path}`;
|
||||
|
||||
try {
|
||||
if (typeof contents === 'string') {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true });
|
||||
} else {
|
||||
await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string }> {
|
||||
const rootPath = `${getFilesRootPath()}/${userId}${path}`;
|
||||
|
||||
try {
|
||||
const contents = await Deno.readFile(join(rootPath, name));
|
||||
|
||||
let contentType = 'application/octet-stream';
|
||||
|
||||
// NOTE: Detecting based on extension is not accurate, but installing a dependency like `npm:file-types` just for this seems unnecessary
|
||||
const extension = name.split('.').slice(-1).join('').toLowerCase();
|
||||
|
||||
if (extension === 'jpg' || extension === 'jpeg') {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (extension === 'png') {
|
||||
contentType = 'image/png';
|
||||
} else if (extension === 'pdf') {
|
||||
contentType = 'application/pdf';
|
||||
} else if (extension === 'txt' || extension === 'md') {
|
||||
contentType = 'text/plain';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contents,
|
||||
contentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
lib/data/news.ts
167
lib/data/news.ts
@@ -1,6 +1,7 @@
|
||||
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
findFeedInUrl,
|
||||
@@ -211,90 +212,98 @@ type JsonFeedArticle = JsonFeed['items'][number];
|
||||
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||
|
||||
export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||
// TODO: Lock this per feedId, so no two processes run this at the same time
|
||||
const lock = new Locker(`feeds:${newsFeed.id}`);
|
||||
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
await lock.acquire();
|
||||
|
||||
if (!feedUrl) {
|
||||
throw new Error(
|
||||
`Invalid URL for feed: "${feedUrl}"`,
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
|
||||
if (!feedUrl) {
|
||||
throw new Error(
|
||||
`Invalid URL for feed: "${feedUrl}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (feedUrl !== newsFeed.feed_url) {
|
||||
newsFeed.feed_url = feedUrl;
|
||||
}
|
||||
|
||||
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||
|
||||
newsFeed.extra.title = feedInfo.title;
|
||||
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||
}
|
||||
|
||||
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||
|
||||
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||
|
||||
for (const feedArticle of feedArticles) {
|
||||
// Don't add too many articles per run
|
||||
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
feedArticle.id;
|
||||
|
||||
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||
(feedArticle as FeedArticle).updated?.toISOString();
|
||||
|
||||
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||
|
||||
const summary = await parseTextFromHtml(
|
||||
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||
(feedArticle as JsonFeedArticle).summary || '',
|
||||
);
|
||||
}
|
||||
|
||||
if (feedUrl !== newsFeed.feed_url) {
|
||||
newsFeed.feed_url = feedUrl;
|
||||
}
|
||||
|
||||
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||
|
||||
newsFeed.extra.title = feedInfo.title;
|
||||
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||
}
|
||||
|
||||
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||
|
||||
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||
|
||||
for (const feedArticle of feedArticles) {
|
||||
// Don't add too many articles per run
|
||||
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
feedArticle.id;
|
||||
|
||||
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||
(feedArticle as FeedArticle).updated?.toISOString();
|
||||
|
||||
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||
|
||||
const summary = await parseTextFromHtml(
|
||||
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||
(feedArticle as JsonFeedArticle).summary || '',
|
||||
);
|
||||
|
||||
if (url) {
|
||||
articles.push({
|
||||
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||
url.replace('http://', '').replace('https://', ''),
|
||||
article_url: url,
|
||||
article_summary: summary,
|
||||
article_date: articleDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingArticles = await getNewsArticlesByFeedId(newsFeed.id);
|
||||
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||
let seenPreviousLatestArticleUrl = false;
|
||||
let addedArticlesCount = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
// Stop looking after seeing the previous latest article
|
||||
if (article.article_url === previousLatestArticleUrl) {
|
||||
seenPreviousLatestArticleUrl = true;
|
||||
}
|
||||
|
||||
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||
try {
|
||||
await createsNewsArticle(newsFeed.user_id, newsFeed.id, article);
|
||||
++addedArticlesCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||
if (url) {
|
||||
articles.push({
|
||||
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||
url.replace('http://', '').replace('https://', ''),
|
||||
article_url: url,
|
||||
article_summary: summary,
|
||||
article_date: articleDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingArticles = await getNewsArticlesByFeedId(newsFeed.id);
|
||||
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||
let seenPreviousLatestArticleUrl = false;
|
||||
let addedArticlesCount = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
// Stop looking after seeing the previous latest article
|
||||
if (article.article_url === previousLatestArticleUrl) {
|
||||
seenPreviousLatestArticleUrl = true;
|
||||
}
|
||||
|
||||
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||
try {
|
||||
await createsNewsArticle(newsFeed.user_id, newsFeed.id, article);
|
||||
++addedArticlesCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Added', addedArticlesCount, 'new articles');
|
||||
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await updateNewsFeed(newsFeed);
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('Added', addedArticlesCount, 'new articles');
|
||||
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await updateNewsFeed(newsFeed);
|
||||
}
|
||||
|
||||
@@ -283,14 +283,3 @@ export async function validateVerificationCode(
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserContactRevision(id: string) {
|
||||
const user = await getUserById(id);
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
user.extra.contacts_revision = revision;
|
||||
user.extra.contacts_updated_at = new Date().toISOString();
|
||||
|
||||
await updateUser(user);
|
||||
}
|
||||
|
||||
124
lib/types.ts
124
lib/types.ts
@@ -12,8 +12,6 @@ export interface User {
|
||||
is_email_verified: boolean;
|
||||
is_admin?: boolean;
|
||||
dav_hashed_password?: string;
|
||||
contacts_revision?: string;
|
||||
contacts_updated_at?: string;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -87,109 +85,45 @@ export interface NewsFeedArticle {
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity
|
||||
export interface Contact {
|
||||
export interface DirectoryOrFileShareLink {
|
||||
url: string;
|
||||
hashed_password: string;
|
||||
}
|
||||
|
||||
export interface FileShare {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revision: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
extra: {
|
||||
name_title?: string;
|
||||
middle_names?: string[];
|
||||
organization?: string;
|
||||
role?: string;
|
||||
photo_url?: string;
|
||||
photo_mediatype?: string;
|
||||
addresses?: ContactAddress[];
|
||||
fields?: ContactField[];
|
||||
notes?: string;
|
||||
uid?: string;
|
||||
nickname?: string;
|
||||
birthday?: string;
|
||||
};
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
label?: string;
|
||||
line_1?: string;
|
||||
line_2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export type ContactFieldType = 'email' | 'phone' | 'url' | 'other';
|
||||
|
||||
export interface ContactField {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
name: string;
|
||||
value: string;
|
||||
type: ContactFieldType;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_visible: boolean;
|
||||
type: 'directory' | 'file';
|
||||
user_ids_with_read_access: string[];
|
||||
user_ids_with_write_access: string[];
|
||||
extra: {
|
||||
shared_read_user_ids?: string[];
|
||||
shared_write_user_ids?: string[];
|
||||
default_transparency: 'opaque' | 'transparent';
|
||||
calendar_timezone?: string;
|
||||
read_share_links: DirectoryOrFileShareLink[];
|
||||
write_share_links: DirectoryOrFileShareLink[];
|
||||
};
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_id: string;
|
||||
revision: string;
|
||||
title: string;
|
||||
start_date: Date;
|
||||
end_date: Date;
|
||||
is_all_day: boolean;
|
||||
status: 'scheduled' | 'pending' | 'canceled';
|
||||
extra: {
|
||||
organizer_email: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
attendees?: CalendarEventAttendee[];
|
||||
transparency: 'default' | Calendar['extra']['default_transparency'];
|
||||
is_recurring?: boolean;
|
||||
recurring_id?: string;
|
||||
recurring_sequence?: number;
|
||||
recurring_rrule?: string;
|
||||
recurring_rdate?: string;
|
||||
recurring_exdate?: string;
|
||||
is_task?: boolean;
|
||||
task_due_date?: string;
|
||||
task_completed_at?: string;
|
||||
uid?: string;
|
||||
reminders?: CalendarEventReminder[];
|
||||
};
|
||||
export interface Directory {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
directory_name: string;
|
||||
has_write_access: boolean;
|
||||
file_share?: FileShare;
|
||||
size_in_bytes: number;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CalendarEventAttendee {
|
||||
email: string;
|
||||
status: 'accepted' | 'rejected' | 'invited';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventReminder {
|
||||
uid?: string;
|
||||
start_date: string;
|
||||
type: 'email' | 'sound' | 'display';
|
||||
acknowledged_at?: string;
|
||||
description?: string;
|
||||
export interface DirectoryFile {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
file_name: string;
|
||||
has_write_access: boolean;
|
||||
file_share?: FileShare;
|
||||
size_in_bytes: number;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
import { Calendar, CalendarEvent, CalendarEventAttendee, CalendarEventReminder } from '/lib/types.ts';
|
||||
|
||||
export const CALENDAR_COLOR_OPTIONS = [
|
||||
'bg-red-700',
|
||||
'bg-red-950',
|
||||
'bg-orange-700',
|
||||
'bg-orange-950',
|
||||
'bg-amber-700',
|
||||
'bg-yellow-800',
|
||||
'bg-lime-700',
|
||||
'bg-lime-950',
|
||||
'bg-green-700',
|
||||
'bg-emerald-800',
|
||||
'bg-teal-700',
|
||||
'bg-cyan-700',
|
||||
'bg-sky-800',
|
||||
'bg-blue-900',
|
||||
'bg-indigo-700',
|
||||
'bg-violet-700',
|
||||
'bg-purple-800',
|
||||
'bg-fuchsia-700',
|
||||
'bg-pink-800',
|
||||
'bg-rose-700',
|
||||
] as const;
|
||||
|
||||
const CALENDAR_COLOR_OPTIONS_HEX = [
|
||||
'#B51E1F',
|
||||
'#450A0A',
|
||||
'#BF4310',
|
||||
'#431407',
|
||||
'#B0550F',
|
||||
'#834F13',
|
||||
'#4D7D16',
|
||||
'#1A2E05',
|
||||
'#148041',
|
||||
'#066048',
|
||||
'#107873',
|
||||
'#0E7490',
|
||||
'#075985',
|
||||
'#1E3A89',
|
||||
'#423BCA',
|
||||
'#6A2BD9',
|
||||
'#6923A9',
|
||||
'#9D21B1',
|
||||
'#9C174D',
|
||||
'#BC133D',
|
||||
] as const;
|
||||
|
||||
// NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file
|
||||
export const CALENDAR_BORDER_COLOR_OPTIONS = [
|
||||
'border-red-700',
|
||||
'border-red-950',
|
||||
'border-orange-700',
|
||||
'border-orange-950',
|
||||
'border-amber-700',
|
||||
'border-yellow-800',
|
||||
'border-lime-700',
|
||||
'border-lime-950',
|
||||
'border-green-700',
|
||||
'border-emerald-800',
|
||||
'border-teal-700',
|
||||
'border-cyan-700',
|
||||
'border-sky-800',
|
||||
'border-blue-900',
|
||||
'border-indigo-700',
|
||||
'border-violet-700',
|
||||
'border-purple-800',
|
||||
'border-fuchsia-700',
|
||||
'border-pink-800',
|
||||
'border-rose-700',
|
||||
] as const;
|
||||
|
||||
export function getColorAsHex(calendarColor: string) {
|
||||
const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor);
|
||||
|
||||
return CALENDAR_COLOR_OPTIONS_HEX[colorIndex];
|
||||
}
|
||||
|
||||
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
|
||||
if (status === 'accepted' || status === 'rejected') {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
return `NEEDS-ACTION`;
|
||||
}
|
||||
|
||||
function getAttendeeStatusFromVCalendar(
|
||||
status: 'NEEDS-ACTION' | 'ACCEPTED' | 'REJECTED',
|
||||
): CalendarEventAttendee['status'] {
|
||||
if (status === 'ACCEPTED' || status === 'REJECTED') {
|
||||
return status.toLowerCase() as CalendarEventAttendee['status'];
|
||||
}
|
||||
|
||||
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('\\,', ',');
|
||||
}
|
||||
|
||||
export function formatCalendarEventsToVCalendar(
|
||||
calendarEvents: CalendarEvent[],
|
||||
calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[],
|
||||
): string {
|
||||
const vCalendarText = calendarEvents.map((calendarEvent) =>
|
||||
`BEGIN:VEVENT
|
||||
DTSTAMP:${getVCalendarDate(calendarEvent.created_at)}
|
||||
DTSTART:${getVCalendarDate(calendarEvent.start_date)}
|
||||
DTEND:${getVCalendarDate(calendarEvent.end_date)}
|
||||
ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email}
|
||||
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
|
||||
TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()}
|
||||
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
|
||||
${
|
||||
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=${
|
||||
getSafelyEscapedTextForVCalendar(attendee.name || '')
|
||||
}:mailto:${attendee.email}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
calendarEvent.extra.reminders?.map((reminder) =>
|
||||
`BEGIN:VALARM
|
||||
ACTION:${reminder.type.toUpperCase()}
|
||||
${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''}
|
||||
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)}
|
||||
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
||||
${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''}
|
||||
END:VALARM`
|
||||
).join('\n') || ''
|
||||
}
|
||||
END:VEVENT`
|
||||
).join('\n');
|
||||
|
||||
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
||||
line.trim()
|
||||
).filter(
|
||||
Boolean,
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
type VCalendarVersion = '1.0' | '2.0';
|
||||
|
||||
export function parseVCalendarFromTextContents(text: string): Partial<CalendarEvent>[] {
|
||||
// Lines that start with a space should be moved to the line above them, as it's the same field/value to parse
|
||||
const lines = text.split('\n').reduce((previousLines, currentLine) => {
|
||||
if (currentLine.startsWith(' ')) {
|
||||
previousLines[previousLines.length - 1] = `${previousLines[previousLines.length - 1]}${
|
||||
currentLine.substring(1).replaceAll('\r', '')
|
||||
}`;
|
||||
} else {
|
||||
previousLines.push(currentLine.replaceAll('\r', ''));
|
||||
}
|
||||
|
||||
return previousLines;
|
||||
}, [] as string[]).map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
||||
|
||||
let partialCalendarEvent: Partial<CalendarEvent> = {};
|
||||
let partialCalendarReminder: Partial<CalendarEventReminder> = {};
|
||||
let vCalendarVersion: VCalendarVersion = '2.0';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new vCard version
|
||||
if (line.startsWith('BEGIN:VCALENDAR')) {
|
||||
vCalendarVersion = '2.0';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new event
|
||||
if (line.startsWith('BEGIN:VEVENT')) {
|
||||
partialCalendarEvent = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish event
|
||||
if (line.startsWith('END:VEVENT')) {
|
||||
partialCalendarEvents.push(partialCalendarEvent);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new reminder
|
||||
if (line.startsWith('BEGIN:VALARM')) {
|
||||
partialCalendarReminder = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish reminder
|
||||
if (line.startsWith('END:VALARM')) {
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
reminders: [...(partialCalendarEvent.extra?.reminders || []), partialCalendarReminder as CalendarEventReminder],
|
||||
};
|
||||
|
||||
partialCalendarReminder = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCalendar version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:1.0')) {
|
||||
vCalendarVersion = '1.0';
|
||||
} else if (line.startsWith('VERSION:2.0')) {
|
||||
vCalendarVersion = '2.0';
|
||||
} else {
|
||||
// Default to 2.0, log warning
|
||||
vCalendarVersion = '2.0';
|
||||
console.warn(`Invalid vCalendar version found: "${line}". Defaulting to 2.0 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCalendarVersion !== '1.0' && vCalendarVersion !== '2.0') {
|
||||
console.warn(`Invalid vCalendar version found: "${vCalendarVersion}". Defaulting to 2.0 parser.`);
|
||||
vCalendarVersion = '2.0';
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '').trim();
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||
partialCalendarReminder.uid = uid;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
uid,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DESCRIPTION:')) {
|
||||
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||
partialCalendarReminder.description = description;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
description,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('SUMMARY:')) {
|
||||
const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim());
|
||||
|
||||
partialCalendarEvent.title = title;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) {
|
||||
const startDateInfo = line.split(':')[1] || '';
|
||||
const [dateInfo, hourInfo] = startDateInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
const startDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
|
||||
partialCalendarEvent.start_date = startDate;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) {
|
||||
const endDateInfo = line.split(':')[1] || '';
|
||||
const [dateInfo, hourInfo] = endDateInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
const endDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
|
||||
partialCalendarEvent.end_date = endDate;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ORGANIZER;')) {
|
||||
const organizerInfo = line.split(':');
|
||||
const organizerEmail = organizerInfo.slice(-1)[0] || '';
|
||||
|
||||
if (!organizerEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
organizer_email: organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.startsWith('TRANSP:')) {
|
||||
const transparency = (line.split('TRANSP:')[1] || 'default')
|
||||
.toLowerCase() as CalendarEvent['extra']['transparency'];
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
transparency,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ATTENDEE;')) {
|
||||
const attendeeInfo = line.split(':');
|
||||
const attendeeEmail = attendeeInfo.slice(-1)[0] || '';
|
||||
const attendeeStatusInfo = line.split('PARTSTAT=')[1] || '';
|
||||
const attendeeStatus = getAttendeeStatusFromVCalendar(
|
||||
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
||||
);
|
||||
const attendeeNameInfo = line.split('CN=')[1] || '';
|
||||
const attendeeName = getSafelyUnescapedTextFromVCalendar((attendeeNameInfo.split(';')[0] || '').trim());
|
||||
|
||||
if (!attendeeEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attendee: CalendarEventAttendee = {
|
||||
email: attendeeEmail,
|
||||
status: attendeeStatus,
|
||||
};
|
||||
|
||||
if (attendeeName) {
|
||||
attendee.name = attendeeName;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
attendees: [...(partialCalendarEvent.extra?.attendees || []), attendee],
|
||||
};
|
||||
}
|
||||
|
||||
if (line.startsWith('ACTION:')) {
|
||||
const reminderType =
|
||||
(line.replace('ACTION:', '').trim().toLowerCase() || 'display') as CalendarEventReminder['type'];
|
||||
|
||||
partialCalendarReminder.type = reminderType;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('TRIGGER:') || line.startsWith('TRIGGER;')) {
|
||||
const triggerInfo = line.split(':')[1] || '';
|
||||
let triggerDate = new Date(partialCalendarEvent.start_date || new Date());
|
||||
|
||||
if (line.includes('DATE-TIME')) {
|
||||
const [dateInfo, hourInfo] = triggerInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
triggerDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
} else {
|
||||
const triggerHoursMatch = triggerInfo.match(/(\d+(?:H))/);
|
||||
const triggerMinutesMatch = triggerInfo.match(/(\d+(?:M))/);
|
||||
const triggerSecondsMatch = triggerInfo.match(/(\d+(?:S))/);
|
||||
|
||||
const isNegative = triggerInfo.startsWith('-');
|
||||
|
||||
if (triggerHoursMatch && triggerHoursMatch.length > 0) {
|
||||
const triggerHours = parseInt(triggerHoursMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setHours(triggerDate.getHours() - triggerHours);
|
||||
} else {
|
||||
triggerDate.setHours(triggerHours);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerMinutesMatch && triggerMinutesMatch.length > 0) {
|
||||
const triggerMinutes = parseInt(triggerMinutesMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setMinutes(triggerDate.getMinutes() - triggerMinutes);
|
||||
} else {
|
||||
triggerDate.setMinutes(triggerMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerSecondsMatch && triggerSecondsMatch.length > 0) {
|
||||
const triggerSeconds = parseInt(triggerSecondsMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setSeconds(triggerDate.getSeconds() - triggerSeconds);
|
||||
} else {
|
||||
triggerDate.setSeconds(triggerSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partialCalendarReminder.start_date = triggerDate.toISOString();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('RRULE:')) {
|
||||
const rRule = line.replace('RRULE:', '').trim();
|
||||
|
||||
if (!rRule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
is_recurring: true,
|
||||
recurring_rrule: rRule,
|
||||
recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('SEQUENCE:')) {
|
||||
const sequence = line.replace('SEQUENCE:', '').trim();
|
||||
|
||||
if (!sequence || sequence === '0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
recurring_sequence: parseInt(sequence, 10),
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialCalendarEvents;
|
||||
}
|
||||
|
||||
// NOTE: Considers weeks starting Monday, not Sunday
|
||||
export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
|
||||
const daysToShow = firstOfMonth.getDay() + (firstOfMonth.getDay() === 0 ? 6 : -1) + lastOfMonth.getDate();
|
||||
|
||||
const weekCount = Math.ceil(daysToShow / 7);
|
||||
|
||||
const weeks: { date: Date; isSameMonth: boolean }[][] = [];
|
||||
|
||||
const startingDate = new Date(firstOfMonth);
|
||||
startingDate.setDate(
|
||||
startingDate.getDate() - Math.abs(firstOfMonth.getDay() === 0 ? 6 : (firstOfMonth.getDay() - 1)),
|
||||
);
|
||||
|
||||
for (let weekIndex = 0; weeks.length < weekCount; ++weekIndex) {
|
||||
for (let dayIndex = 0; dayIndex < 7; ++dayIndex) {
|
||||
if (!Array.isArray(weeks[weekIndex])) {
|
||||
weeks[weekIndex] = [];
|
||||
}
|
||||
|
||||
const weekDayDate = new Date(startingDate);
|
||||
weekDayDate.setDate(weekDayDate.getDate() + (dayIndex + weekIndex * 7));
|
||||
|
||||
const isSameMonth = weekDayDate.getMonth() === month;
|
||||
|
||||
weeks[weekIndex].push({ date: weekDayDate, isSameMonth });
|
||||
}
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
// NOTE: Considers week starting Monday, not Sunday
|
||||
export function getDaysForWeek(
|
||||
date: Date,
|
||||
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
|
||||
const shortIsoDate = new Date().toISOString().substring(0, 10);
|
||||
const currentHour = new Date().getHours();
|
||||
|
||||
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
||||
|
||||
const startingDate = new Date(date);
|
||||
startingDate.setDate(
|
||||
startingDate.getDate() - Math.abs(startingDate.getDay() === 0 ? 6 : (startingDate.getDay() - 1)),
|
||||
);
|
||||
|
||||
for (let dayIndex = 0; days.length < 7; ++dayIndex) {
|
||||
const dayDate = new Date(startingDate);
|
||||
dayDate.setDate(dayDate.getDate() + dayIndex);
|
||||
|
||||
const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate;
|
||||
|
||||
days[dayIndex] = {
|
||||
date: dayDate,
|
||||
isSameDay,
|
||||
hours: [],
|
||||
};
|
||||
|
||||
for (let hourIndex = 0; hourIndex < 24; ++hourIndex) {
|
||||
const dayHourDate = new Date(dayDate);
|
||||
dayHourDate.setHours(hourIndex);
|
||||
|
||||
const isCurrentHour = isSameDay && hourIndex === currentHour;
|
||||
|
||||
days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour });
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function getCalendarEventTransparency(
|
||||
calendarEvent: CalendarEvent,
|
||||
calendars: Pick<Calendar, 'id' | 'extra'>[],
|
||||
) {
|
||||
const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id);
|
||||
|
||||
const transparency = calendarEvent.extra.transparency === 'default'
|
||||
? (matchingCalendar?.extra.default_transparency || 'opaque')
|
||||
: calendarEvent.extra.transparency;
|
||||
|
||||
return transparency;
|
||||
}
|
||||
|
||||
export function getCalendarEventColor(
|
||||
calendarEvent: CalendarEvent,
|
||||
calendars: Pick<Calendar, 'id' | 'color' | 'extra'>[],
|
||||
) {
|
||||
const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id);
|
||||
const opaqueColor = matchingCalendar?.color || 'bg-gray-700';
|
||||
const transparentColor = opaqueColor.replace('bg-', 'border border-');
|
||||
|
||||
const transparency = getCalendarEventTransparency(calendarEvent, calendars);
|
||||
|
||||
return transparency === 'opaque' ? opaqueColor : transparentColor;
|
||||
}
|
||||
|
||||
type RRuleFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
type RRuleWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
|
||||
type RRuleType = 'FREQ' | 'BYDAY' | 'BYMONTHDAY' | 'BYHOUR' | 'BYMINUTE' | 'COUNT' | 'INTERVAL';
|
||||
|
||||
const rRuleToFrequencyOrWeekDay = new Map<RRuleFrequency | RRuleWeekDay, string>([
|
||||
['DAILY', 'day'],
|
||||
['WEEKLY', 'week'],
|
||||
['MONTHLY', 'month'],
|
||||
['MO', 'Monday'],
|
||||
['TU', 'Tuesday'],
|
||||
['WE', 'Wednesday'],
|
||||
['TH', 'Thursday'],
|
||||
['FR', 'Friday'],
|
||||
['SA', 'Saturday'],
|
||||
['SU', 'Sunday'],
|
||||
]);
|
||||
|
||||
function convertRRuleDaysToWords(day: string | RRuleFrequency | RRuleWeekDay): string {
|
||||
if (day.includes(',')) {
|
||||
const days = day.split(',') as (typeof day)[];
|
||||
return days.map((individualDay) => rRuleToFrequencyOrWeekDay.get(individualDay as RRuleFrequency | RRuleWeekDay))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
return rRuleToFrequencyOrWeekDay.get(day as RRuleFrequency | RRuleWeekDay)!;
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(number: number) {
|
||||
const text = ['th', 'st', 'nd', 'rd'] as const;
|
||||
const value = number % 100;
|
||||
return `${number}${(text[(value - 20) % 10] || text[value] || text[0])}`;
|
||||
}
|
||||
|
||||
export function convertRRuleToWords(rRule: string): string {
|
||||
const rulePart = rRule.replace('RRULE:', '');
|
||||
|
||||
const rulePieces = rulePart.split(';');
|
||||
|
||||
const parsedRRule: Partial<Record<RRuleType, string>> = {};
|
||||
|
||||
rulePieces.forEach(function (rulePiece) {
|
||||
const keyAndValue = rulePiece.split('=') as [RRuleType, string];
|
||||
const [key, value] = keyAndValue;
|
||||
|
||||
parsedRRule[key] = value;
|
||||
});
|
||||
|
||||
const frequency = parsedRRule.FREQ;
|
||||
const byDay = parsedRRule.BYDAY;
|
||||
const byMonthDay = parsedRRule.BYMONTHDAY;
|
||||
const byHour = parsedRRule.BYHOUR;
|
||||
const byMinute = parsedRRule.BYMINUTE;
|
||||
const count = parsedRRule.COUNT;
|
||||
const interval = parsedRRule.INTERVAL;
|
||||
|
||||
const words: string[] = [];
|
||||
|
||||
if (frequency === 'DAILY') {
|
||||
if (byHour) {
|
||||
if (byMinute) {
|
||||
words.push(`Every day at ${byHour}:${byMinute}`);
|
||||
} else {
|
||||
words.push(`Every day at ${byHour}:00`);
|
||||
}
|
||||
} else {
|
||||
words.push(`Every day`);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(`for 1 time`);
|
||||
} else {
|
||||
words.push(`for ${count} times`);
|
||||
}
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
if (frequency === 'WEEKLY') {
|
||||
if (byDay) {
|
||||
if (interval && parseInt(interval, 10) > 1) {
|
||||
words.push(
|
||||
`Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on ${convertRRuleDaysToWords(byDay)}`,
|
||||
);
|
||||
} else {
|
||||
words.push(`Every ${rRuleToFrequencyOrWeekDay.get(frequency)} on ${convertRRuleDaysToWords(byDay)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (byMonthDay) {
|
||||
words.push(`the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(`for 1 time`);
|
||||
} else {
|
||||
words.push(`for ${count} times`);
|
||||
}
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
// monthly
|
||||
if (frequency === 'MONTHLY' && byMonthDay) {
|
||||
if (interval && parseInt(interval, 10) > 1) {
|
||||
words.push(
|
||||
`Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on the ${
|
||||
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
words.push(
|
||||
`Every ${rRuleToFrequencyOrWeekDay.get(frequency)} on the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(` for 1 time`);
|
||||
} else {
|
||||
words.push(` for ${count} times`);
|
||||
}
|
||||
}
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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
|
||||
VERSION:4.0
|
||||
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:${getSafelyEscapedTextForVCard(contact.extra.organization)}` : ''}
|
||||
${contact.extra.role ? `TITLE:${getSafelyEscapedTextForVCard(contact.extra.role)}` : ''}
|
||||
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
|
||||
${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) =>
|
||||
`TEL;TYPE=${phone.name}:${phone.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.addresses?.map((address) =>
|
||||
`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') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'email').map((email) =>
|
||||
`EMAIL;TYPE=${email.name}:${email.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
REV:${new Date(contact.updated_at).toISOString()}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
||||
.join('\n') || ''
|
||||
}
|
||||
${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''}
|
||||
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
||||
END:VCARD`
|
||||
).join('\n');
|
||||
|
||||
return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||
|
||||
export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
||||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialContacts: Partial<Contact>[] = [];
|
||||
|
||||
let partialContact: Partial<Contact> = {};
|
||||
let vCardVersion: VCardVersion = '2.1';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new contact and vCard version
|
||||
if (line.startsWith('BEGIN:VCARD')) {
|
||||
partialContact = {};
|
||||
vCardVersion = '2.1';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish contact
|
||||
if (line.startsWith('END:VCARD')) {
|
||||
partialContacts.push(partialContact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCard version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:2.1')) {
|
||||
vCardVersion = '2.1';
|
||||
} else if (line.startsWith('VERSION:3.0')) {
|
||||
vCardVersion = '3.0';
|
||||
} else if (line.startsWith('VERSION:4.0')) {
|
||||
vCardVersion = '4.0';
|
||||
} else {
|
||||
// Default to 2.1, log warning
|
||||
vCardVersion = '2.1';
|
||||
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||
vCardVersion = '2.1';
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '');
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
uid,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('N:')) {
|
||||
const names = line.split('N:')[1].split(';');
|
||||
|
||||
const lastName = names[0] || '';
|
||||
const firstName = names[1] || '';
|
||||
const middleNames = names.slice(2, -1).filter(Boolean);
|
||||
const title = names.slice(-1).join(' ') || '';
|
||||
|
||||
if (!firstName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.first_name = firstName;
|
||||
partialContact.last_name = lastName;
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
middle_names: middleNames,
|
||||
name_title: title,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ORG:')) {
|
||||
const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ',');
|
||||
|
||||
if (!organization) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
organization,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BDAY:')) {
|
||||
const birthday = line.split('BDAY:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
birthday,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NICKNAME:')) {
|
||||
const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || '';
|
||||
|
||||
if (!nickname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
nickname,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('TITLE:')) {
|
||||
const role = line.split('TITLE:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
role,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NOTE:')) {
|
||||
const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || '');
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
notes,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('ADR;')) {
|
||||
const addressInfo = line.split('ADR;')[1] || '';
|
||||
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
||||
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 = getSafelyUnescapedTextFromVCard(
|
||||
((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''),
|
||||
);
|
||||
|
||||
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address: ContactAddress = {
|
||||
label,
|
||||
line_1: line1,
|
||||
line_2: line2,
|
||||
city,
|
||||
state,
|
||||
postal_code: postalCode,
|
||||
country,
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
addresses: [...(partialContact.extra?.addresses || []), address],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('PHOTO;')) {
|
||||
const photoInfo = line.split('PHOTO;')[1] || '';
|
||||
const photoUrl = photoInfo.split(':')[1];
|
||||
const photoMediaTypeInfo = photoInfo.split(':')[0];
|
||||
let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || '';
|
||||
|
||||
if (!photoMediaType) {
|
||||
photoMediaType = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!photoMediaType.startsWith('image/')) {
|
||||
photoMediaType = `image/${photoMediaType.toLowerCase()}`;
|
||||
}
|
||||
|
||||
if (!photoUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
photo_mediatype: photoMediaType,
|
||||
photo_url: photoUrl,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('TEL;')) {
|
||||
const phoneInfo = line.split('TEL;')[1] || '';
|
||||
const phoneNumber = phoneInfo.split(':')[1] || '';
|
||||
const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!phoneNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: phoneNumber,
|
||||
type: 'phone',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('EMAIL;')) {
|
||||
const emailInfo = line.split('EMAIL;')[1] || '';
|
||||
const emailAddress = emailInfo.split(':')[1] || '';
|
||||
const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: emailAddress,
|
||||
type: 'email',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialContacts;
|
||||
}
|
||||
57
lib/utils/files.ts
Normal file
57
lib/utils/files.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
|
||||
export const TRASH_PATH = `/.Trash/`;
|
||||
|
||||
export function humanFileSize(bytes: number) {
|
||||
if (Math.abs(bytes) < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
let unitIndex = -1;
|
||||
const roundedPower = 10 ** 2;
|
||||
|
||||
do {
|
||||
bytes /= 1024;
|
||||
++unitIndex;
|
||||
} while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1);
|
||||
|
||||
return `${bytes.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) {
|
||||
if (entryA.name > entryB.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (entryA.name < entryB.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortDirectoriesByName(directoryA: Directory, directoryB: Directory) {
|
||||
if (directoryA.directory_name > directoryB.directory_name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (directoryA.directory_name < directoryB.directory_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) {
|
||||
if (fileA.file_name > fileB.file_name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (fileA.file_name < fileB.file_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
36
lib/utils/files_test.ts
Normal file
36
lib/utils/files_test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { humanFileSize } from './files.ts';
|
||||
|
||||
Deno.test('that humanFileSize works', () => {
|
||||
const tests: { input: number; expected: string }[] = [
|
||||
{
|
||||
input: 1000,
|
||||
expected: '1000 B',
|
||||
},
|
||||
{
|
||||
input: 1024,
|
||||
expected: '1.00 KB',
|
||||
},
|
||||
{
|
||||
input: 10000,
|
||||
expected: '9.77 KB',
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: '1 B',
|
||||
},
|
||||
{
|
||||
input: 1048576,
|
||||
expected: '1.00 MB',
|
||||
},
|
||||
{
|
||||
input: 1073741824,
|
||||
expected: '1.00 GB',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = humanFileSize(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
@@ -10,15 +10,9 @@ if (typeof Deno !== 'undefined') {
|
||||
|
||||
export const baseUrl = BASE_URL || 'http://localhost:8000';
|
||||
export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
||||
export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`;
|
||||
export const defaultDescription = `Have your files under your own control.`;
|
||||
export const helpEmail = 'help@bewcloud.com';
|
||||
|
||||
export const DAV_RESPONSE_HEADER = '1, 2, 3, 4, addressbook, calendar-access';
|
||||
// Response headers from Nextcloud:
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
|
||||
export function isRunningLocally(request: Request) {
|
||||
return request.url.includes('localhost');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user