Files CRUD.

Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
Bruno Bernardino
2024-04-03 14:02:04 +01:00
parent c4788761d2
commit 4e5fdd569a
89 changed files with 2302 additions and 8001 deletions

View File

@@ -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(' ');
}

View File

@@ -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
View 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
View 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);
}
});

View File

@@ -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');
}