diff --git a/components/calendar/CalendarViewDay.tsx b/components/calendar/CalendarViewDay.tsx index df7c9b2..ea9a336 100644 --- a/components/calendar/CalendarViewDay.tsx +++ b/components/calendar/CalendarViewDay.tsx @@ -1,8 +1,9 @@ import { Calendar, CalendarEvent } from '/lib/types.ts'; +import { getCalendarEventColor } from '/lib/utils/calendar.ts'; interface CalendarViewDayProps { startDate: Date; - visibleCalendars: Pick[]; + visibleCalendars: Pick[]; calendarEvents: CalendarEvent[]; onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; onClickOpenEvent: (calendarEvent: CalendarEvent) => void; @@ -107,8 +108,7 @@ export default function CalendarViewDay( calendar.id === calendarEvent.calendar_id) - ?.color || 'bg-gray-700' + getCalendarEventColor(calendarEvent, visibleCalendars) }`} onClick={() => onClickOpenEvent(calendarEvent)} > @@ -195,8 +195,7 @@ export default function CalendarViewDay( calendar.id === hourEvent.calendar_id) - ?.color || 'bg-gray-700' + getCalendarEventColor(hourEvent, visibleCalendars) }`} onClick={() => onClickOpenEvent(hourEvent)} > diff --git a/components/calendar/CalendarViewMonth.tsx b/components/calendar/CalendarViewMonth.tsx index b14a9d4..63004b0 100644 --- a/components/calendar/CalendarViewMonth.tsx +++ b/components/calendar/CalendarViewMonth.tsx @@ -1,9 +1,9 @@ import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { getWeeksForMonth } from '/lib/utils.ts'; +import { getCalendarEventColor, getWeeksForMonth } from '/lib/utils/calendar.ts'; interface CalendarViewWeekProps { startDate: Date; - visibleCalendars: Pick[]; + visibleCalendars: Pick[]; calendarEvents: CalendarEvent[]; onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; onClickOpenEvent: (calendarEvent: CalendarEvent) => void; @@ -116,8 +116,7 @@ export default function CalendarViewWeek( calendar.id === dayEvent.calendar_id) - ?.color || 'bg-gray-700' + getCalendarEventColor(dayEvent, visibleCalendars) }`} onClick={() => onClickOpenEvent(dayEvent)} > diff --git a/components/calendar/CalendarViewWeek.tsx b/components/calendar/CalendarViewWeek.tsx index 73b4d93..7f13326 100644 --- a/components/calendar/CalendarViewWeek.tsx +++ b/components/calendar/CalendarViewWeek.tsx @@ -1,9 +1,9 @@ import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { getDaysForWeek } from '/lib/utils.ts'; +import { getCalendarEventColor, getDaysForWeek } from '/lib/utils/calendar.ts'; interface CalendarViewWeekProps { startDate: Date; - visibleCalendars: Pick[]; + visibleCalendars: Pick[]; calendarEvents: CalendarEvent[]; onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; onClickOpenEvent: (calendarEvent: CalendarEvent) => void; @@ -97,8 +97,7 @@ export default function CalendarViewWeek( calendar.id === calendarEvent.calendar_id) - ?.color || 'bg-gray-700' + getCalendarEventColor(calendarEvent, visibleCalendars) }`} onClick={() => onClickOpenEvent(calendarEvent)} > @@ -186,8 +185,7 @@ export default function CalendarViewWeek( calendar.id === hourEvent.calendar_id) - ?.color || 'bg-gray-700' + getCalendarEventColor(hourEvent, visibleCalendars) }`} onClick={() => onClickOpenEvent(hourEvent)} > diff --git a/components/calendar/MainCalendar.tsx b/components/calendar/MainCalendar.tsx index 19a7370..d7384b1 100644 --- a/components/calendar/MainCalendar.tsx +++ b/components/calendar/MainCalendar.tsx @@ -1,12 +1,8 @@ import { useSignal } from '@preact/signals'; import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { - baseUrl, - capitalizeWord, - formatCalendarEventsToVCalendar, - parseVCalendarFromTextContents, -} from '/lib/utils.ts'; +import { baseUrl, capitalizeWord } from '/lib/utils/misc.ts'; +import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/calendar/get-events.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx'; import { @@ -23,7 +19,7 @@ import SearchEvents from './SearchEvents.tsx'; import ImportEventsModal from './ImportEventsModal.tsx'; interface MainCalendarProps { - initialCalendars: Pick[]; + initialCalendars: Pick[]; initialCalendarEvents: CalendarEvent[]; view: 'day' | 'week' | 'month'; startDate: string; @@ -34,7 +30,7 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents, const isDeleting = useSignal(false); const isExporting = useSignal(false); const isImporting = useSignal(false); - const calendars = useSignal[]>(initialCalendars); + const calendars = useSignal[]>(initialCalendars); const isViewOptionsDropdownOpen = useSignal(false); const isImportExportOptionsDropdownOpen = useSignal(false); const calendarEvents = useSignal(initialCalendarEvents); diff --git a/crons/news.ts b/crons/news.ts index 6c985d5..3be18b4 100644 --- a/crons/news.ts +++ b/crons/news.ts @@ -1,6 +1,6 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { NewsFeed } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; import { crawlNewsFeed } from '/lib/data/news.ts'; const db = new Database(); diff --git a/islands/Settings.tsx b/islands/Settings.tsx index f2a69b0..ab13b9f 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -1,4 +1,4 @@ -import { convertObjectToFormData, helpEmail } from '/lib/utils.ts'; +import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; interface SettingsProps { diff --git a/islands/calendar/CalendarWrapper.tsx b/islands/calendar/CalendarWrapper.tsx index 67db089..3c4de91 100644 --- a/islands/calendar/CalendarWrapper.tsx +++ b/islands/calendar/CalendarWrapper.tsx @@ -2,7 +2,7 @@ import { Calendar, CalendarEvent } from '/lib/types.ts'; import MainCalendar from '/components/calendar/MainCalendar.tsx'; interface CalendarWrapperProps { - initialCalendars: Pick[]; + initialCalendars: Pick[]; initialCalendarEvents: CalendarEvent[]; view: 'day' | 'week' | 'month'; startDate: string; diff --git a/islands/calendar/Calendars.tsx b/islands/calendar/Calendars.tsx index 6e85273..e716771 100644 --- a/islands/calendar/Calendars.tsx +++ b/islands/calendar/Calendars.tsx @@ -1,7 +1,7 @@ import { useSignal } from '@preact/signals'; import { Calendar } from '/lib/types.ts'; -import { CALENDAR_COLOR_OPTIONS } from '/lib/utils.ts'; +import { CALENDAR_COLOR_OPTIONS } from '/lib/utils/calendar.ts'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx'; import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx'; diff --git a/islands/calendar/ViewCalendarEvent.tsx b/islands/calendar/ViewCalendarEvent.tsx index 107a706..d8fa331 100644 --- a/islands/calendar/ViewCalendarEvent.tsx +++ b/islands/calendar/ViewCalendarEvent.tsx @@ -1,7 +1,7 @@ import { useSignal } from '@preact/signals'; import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { capitalizeWord, convertObjectToFormData } from '/lib/utils.ts'; +import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; import { RequestBody as DeleteRequestBody, diff --git a/islands/contacts/Contacts.tsx b/islands/contacts/Contacts.tsx index 9da1683..bc25d67 100644 --- a/islands/contacts/Contacts.tsx +++ b/islands/contacts/Contacts.tsx @@ -1,7 +1,8 @@ import { useSignal } from '@preact/signals'; import { Contact } from '/lib/types.ts'; -import { baseUrl, CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils.ts'; +import { baseUrl } from '/lib/utils/misc.ts'; +import { CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts'; import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; diff --git a/islands/contacts/ViewContact.tsx b/islands/contacts/ViewContact.tsx index f67189e..0bdd624 100644 --- a/islands/contacts/ViewContact.tsx +++ b/islands/contacts/ViewContact.tsx @@ -1,7 +1,7 @@ import { useSignal } from '@preact/signals'; import { Contact } from '/lib/types.ts'; -import { convertObjectToFormData } from '/lib/utils.ts'; +import { convertObjectToFormData } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; diff --git a/islands/dashboard/Links.tsx b/islands/dashboard/Links.tsx index 8b2a5bd..7b03318 100644 --- a/islands/dashboard/Links.tsx +++ b/islands/dashboard/Links.tsx @@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals'; import { useEffect } from 'preact/hooks'; import { DashboardLink } from '/lib/types.ts'; -import { validateUrl } from '/lib/utils.ts'; +import { validateUrl } from '/lib/utils/misc.ts'; import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx'; interface LinksProps { diff --git a/islands/news/Feeds.tsx b/islands/news/Feeds.tsx index 6cbacf9..372150b 100644 --- a/islands/news/Feeds.tsx +++ b/islands/news/Feeds.tsx @@ -1,7 +1,7 @@ import { useSignal } from '@preact/signals'; import { NewsFeed } from '/lib/types.ts'; -import { escapeHtml, validateUrl } from '/lib/utils.ts'; +import { escapeHtml, validateUrl } from '/lib/utils/misc.ts'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx'; import { diff --git a/lib/auth.ts b/lib/auth.ts index ea8463d..563d5eb 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,7 +3,7 @@ import { decodeBase64 } from 'std/encoding/base64.ts'; import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import 'std/dotenv/load.ts'; -import { baseUrl, generateHash, isRunningLocally } from './utils.ts'; +import { baseUrl, generateHash, isRunningLocally } from './utils/misc.ts'; import { User, UserSession } from './types.ts'; import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.ts'; diff --git a/lib/data/calendar.ts b/lib/data/calendar.ts index 25d48d3..446aa5d 100644 --- a/lib/data/calendar.ts +++ b/lib/data/calendar.ts @@ -1,6 +1,7 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { CALENDAR_COLOR_OPTIONS, getRandomItem } from '/lib/utils.ts'; +import { getRandomItem } from '/lib/utils/misc.ts'; +import { CALENDAR_COLOR_OPTIONS } from '/lib/utils/calendar.ts'; import { getUserById } from './user.ts'; const db = new Database(); diff --git a/lib/data/contacts.ts b/lib/data/contacts.ts index 5e536d6..5fe9024 100644 --- a/lib/data/contacts.ts +++ b/lib/data/contacts.ts @@ -1,6 +1,6 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { Contact } from '/lib/types.ts'; -import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils.ts'; +import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils/contacts.ts'; import { updateUserContactRevision } from './user.ts'; const db = new Database(); diff --git a/lib/data/user.ts b/lib/data/user.ts index b169e70..b2bceb3 100644 --- a/lib/data/user.ts +++ b/lib/data/user.ts @@ -1,6 +1,6 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { User, UserSession, VerificationCode } from '/lib/types.ts'; -import { generateRandomCode } from '/lib/utils.ts'; +import { generateRandomCode } from '/lib/utils/misc.ts'; const db = new Database(); diff --git a/lib/feed.ts b/lib/feed.ts index a44e796..99016fe 100644 --- a/lib/feed.ts +++ b/lib/feed.ts @@ -1,6 +1,6 @@ import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts'; import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; -import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils.ts'; +import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils/misc.ts'; import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts'; export interface JsonFeedItem { diff --git a/lib/providers/brevo.ts b/lib/providers/brevo.ts index f53a7ea..1908c14 100644 --- a/lib/providers/brevo.ts +++ b/lib/providers/brevo.ts @@ -1,6 +1,6 @@ import 'std/dotenv/load.ts'; -import { helpEmail } from '/lib/utils.ts'; +import { helpEmail } from '/lib/utils/misc.ts'; const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || ''; diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index 780f75a..0000000 --- a/lib/utils.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { Calendar, CalendarEvent, Contact, ContactAddress, ContactField } from './types.ts'; - -let BASE_URL = typeof window !== 'undefined' && window.location - ? `${window.location.protocol}//${window.location.host}` - : ''; - -if (typeof Deno !== 'undefined') { - await import('std/dotenv/load.ts'); - - BASE_URL = Deno.env.get('BASE_URL') || ''; -} - -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 helpEmail = 'help@bewcloud.com'; - -export const CONTACTS_PER_PAGE_COUNT = 20; - -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 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; - -export function isRunningLocally(request: Request) { - return request.url.includes('localhost'); -} - -export function escapeHtml(unsafe: string) { - return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') - .replaceAll("'", '''); -} - -export function escapeXml(unsafe: string) { - return escapeHtml(unsafe).replaceAll('\r', ' '); -} - -export function generateRandomCode(length = 6) { - const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9 - - const codeDigits = Array.from({ length }).map(getRandomDigit); - - return codeDigits.join(''); -} - -export async function generateHash(value: string, algorithm: AlgorithmIdentifier) { - const hashedValueData = await crypto.subtle.digest( - algorithm, - new TextEncoder().encode(value), - ); - - const hashedValue = Array.from(new Uint8Array(hashedValueData)).map( - (byte) => byte.toString(16).padStart(2, '0'), - ).join(''); - - return hashedValue; -} - -export function splitArrayInChunks(array: T[], chunkLength: number) { - const chunks = []; - let chunkIndex = 0; - const arrayLength = array.length; - - while (chunkIndex < arrayLength) { - chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength)); - } - - return chunks; -} - -export function validateEmail(email: string) { - const trimmedEmail = (email || '').trim().toLocaleLowerCase(); - if (!trimmedEmail) { - return false; - } - - const requiredCharsNotInEdges = ['@', '.']; - return requiredCharsNotInEdges.every((char) => - trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char) - ); -} - -export function validateUrl(url: string) { - const trimmedUrl = (url || '').trim().toLocaleLowerCase(); - if (!trimmedUrl) { - return false; - } - - if (!trimmedUrl.includes('://')) { - return false; - } - - const protocolIndex = trimmedUrl.indexOf('://'); - const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3); - - if (!urlAfterProtocol) { - return false; - } - - return true; -} - -// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a -export async function concurrentPromises( - generators: (() => Promise)[], - maxConcurrency: number, -): Promise { - const iterator = generators.entries(); - - const results: T[] = []; - - let hasFailed = false; - - await Promise.all( - Array.from(Array(maxConcurrency), async () => { - for (const [index, promiseToExecute] of iterator) { - if (hasFailed) { - break; - } - try { - results[index] = await promiseToExecute(); - } catch (error) { - hasFailed = true; - throw error; - } - } - }), - ); - - return results; -} - -const MAX_RESPONSE_TIME_IN_MS = 10 * 1000; - -export async function fetchUrl(url: string) { - const abortController = new AbortController(); - const requestCancelTimeout = setTimeout(() => { - abortController.abort(); - }, MAX_RESPONSE_TIME_IN_MS); - - const response = await fetch(url, { - signal: abortController.signal, - }); - - if (requestCancelTimeout) { - clearTimeout(requestCancelTimeout); - } - - const urlContents = await response.text(); - return urlContents; -} - -export async function fetchUrlAsGooglebot(url: string) { - const abortController = new AbortController(); - const requestCancelTimeout = setTimeout(() => { - abortController.abort(); - }, MAX_RESPONSE_TIME_IN_MS); - - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', - }, - signal: abortController.signal, - }); - - if (requestCancelTimeout) { - clearTimeout(requestCancelTimeout); - } - - const urlContents = await response.text(); - return urlContents; -} - -export async function fetchUrlWithProxy(url: string) { - const abortController = new AbortController(); - const requestCancelTimeout = setTimeout(() => { - abortController.abort(); - }, MAX_RESPONSE_TIME_IN_MS); - - const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, { - signal: abortController.signal, - }); - - if (requestCancelTimeout) { - clearTimeout(requestCancelTimeout); - } - - const urlContents = await response.text(); - return urlContents; -} - -export async function fetchUrlWithRetries(url: string) { - try { - const text = await fetchUrl(url); - return text; - } catch (_error) { - try { - const text = await fetchUrlAsGooglebot(url); - return text; - } catch (_error) { - const text = await fetchUrlWithProxy(url); - return text; - } - } -} - -export function convertFormDataToObject(formData: FormData): Record { - return JSON.parse(JSON.stringify(Object.fromEntries(formData))); -} - -export function convertObjectToFormData(formDataObject: Record): FormData { - const formData = new FormData(); - - for (const key of Object.keys(formDataObject || {})) { - if (Array.isArray(formDataObject[key])) { - formData.append(key, formDataObject[key].join(',')); - } else { - formData.append(key, formDataObject[key]); - } - } - - return formData; -} - -function writeXmlTag(tagName: string, value: any, attributes?: Record) { - const attributesXml = attributes - ? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`) - .join(' ') - : ''; - - if (Array.isArray(value)) { - if (value.length === 0) { - return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; - } - - const xmlLines: string[] = []; - - for (const valueItem of value) { - xmlLines.push(writeXmlTag(tagName, valueItem)); - } - - return xmlLines.join('\n'); - } - - if (typeof value === 'object') { - if (Object.keys(value).length === 0) { - return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; - } - - return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}`; - } - - return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}`; -} - -export function convertObjectToDavXml(davObject: Record, isInitial = false): string { - const xmlLines: string[] = []; - - if (isInitial) { - xmlLines.push(``); - } - - for (const key of Object.keys(davObject)) { - if (key.endsWith('_attributes')) { - continue; - } - - xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`])); - } - - return xmlLines.join('\n'); -} - -function addLeadingZero(number: number) { - if (number < 10) { - return `0${number}`; - } - - return number.toString(); -} - -export function buildRFC822Date(dateString: string) { - const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - const timeStamp = Date.parse(dateString); - const date = new Date(timeStamp); - - const day = dayStrings[date.getDay()]; - const dayNumber = addLeadingZero(date.getUTCDate()); - const month = monthStrings[date.getUTCMonth()]; - const year = date.getUTCFullYear(); - const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`; - - return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`; -} - -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:${contact.extra.organization.replaceAll(',', '\\,')}` : ''} -${contact.extra.role ? `TITLE:${contact.extra.role}` : ''} -${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''} -${contact.extra.nickname ? `NICKNAME:${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}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - };${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - };${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - }` - ).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:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}` - : '' - } -${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[] { - const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); - - const partialContacts: Partial[] = []; - - let partialContact: Partial = {}; - 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 = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ','); - - partialContact.extra = { - ...(partialContact.extra || {}), - notes, - }; - - continue; - } - - if (line.includes('ADR;')) { - const addressInfo = line.split('ADR;')[1] || ''; - const addressParts = (addressInfo.split(':')[1] || '').split(';'); - const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - - const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll( - '\\n', - '\n', - ).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; -} - -// TODO: Build this -export function formatCalendarEventsToVCalendar( - calendarEvents: CalendarEvent[], - _calendars: Pick[], -): string { - const vCalendarText = calendarEvents.map((calendarEvent) => - `BEGIN:VEVENT -DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -ORGANIZER;CN=:MAILTO:${calendarEvent.extra.organizer_email} -SUMMARY:${calendarEvent.title} -${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''} -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[] { - const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); - - const partialCalendarEvents: Partial[] = []; - - let partialCalendarEvent: Partial = {}; - 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 contact - if (line.startsWith('END:VEVENT')) { - partialCalendarEvents.push(partialCalendarEvent); - 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:', ''); - - if (!uid) { - continue; - } - - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - uid, - }; - - continue; - } - - // TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters ) - - if (line.startsWith('SUMMARY:')) { - const title = line.split('SUMMARY:')[1] || ''; - - partialCalendarEvent.title = title; - - continue; - } - - if (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')) { - 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; - } - } - - return partialCalendarEvents; -} - -export const capitalizeWord = (string: string) => { - return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`; -}; - -// 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 = 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; -} - -export function getRandomItem(items: Readonly>): T { - return items[Math.floor(Math.random() * items.length)]; -} diff --git a/lib/utils/calendar.ts b/lib/utils/calendar.ts new file mode 100644 index 0000000..034ca9b --- /dev/null +++ b/lib/utils/calendar.ts @@ -0,0 +1,279 @@ +import { Calendar, CalendarEvent } from '../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; + +// 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; + +// TODO: Build this +export function formatCalendarEventsToVCalendar( + calendarEvents: CalendarEvent[], + _calendars: Pick[], +): string { + const vCalendarText = calendarEvents.map((calendarEvent) => + `BEGIN:VEVENT +DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} +DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} +DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} +ORGANIZER;CN=:MAILTO:${calendarEvent.extra.organizer_email} +SUMMARY:${calendarEvent.title} +${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''} +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[] { + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + + const partialCalendarEvents: Partial[] = []; + + let partialCalendarEvent: Partial = {}; + 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 contact + if (line.startsWith('END:VEVENT')) { + partialCalendarEvents.push(partialCalendarEvent); + 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:', ''); + + if (!uid) { + continue; + } + + partialCalendarEvent.extra = { + ...(partialCalendarEvent.extra! || {}), + uid, + }; + + continue; + } + + // TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters ) + + if (line.startsWith('SUMMARY:')) { + const title = line.split('SUMMARY:')[1] || ''; + + partialCalendarEvent.title = title; + + continue; + } + + if (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')) { + 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; + } + } + + 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 = 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; +} + +export function getCalendarEventColor( + calendarEvent: CalendarEvent, + calendars: Pick[], +) { + 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 = calendarEvent.extra.transparency === 'default' + ? (matchingCalendar?.extra.default_transparency || 'opaque') + : calendarEvent.extra.transparency; + + return transparency === 'opaque' ? opaqueColor : transparentColor; +} diff --git a/lib/utils/contacts.ts b/lib/utils/contacts.ts new file mode 100644 index 0000000..92b545c --- /dev/null +++ b/lib/utils/contacts.ts @@ -0,0 +1,334 @@ +import { Contact, ContactAddress, ContactField } from '../types.ts'; + +export const CONTACTS_PER_PAGE_COUNT = 20; + +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:${contact.extra.organization.replaceAll(',', '\\,')}` : ''} +${contact.extra.role ? `TITLE:${contact.extra.role}` : ''} +${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''} +${contact.extra.nickname ? `NICKNAME:${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}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + };${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + };${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + }` + ).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:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}` + : '' + } +${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[] { + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + + const partialContacts: Partial[] = []; + + let partialContact: Partial = {}; + 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 = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ','); + + partialContact.extra = { + ...(partialContact.extra || {}), + notes, + }; + + continue; + } + + if (line.includes('ADR;')) { + const addressInfo = line.split('ADR;')[1] || ''; + const addressParts = (addressInfo.split(':')[1] || '').split(';'); + const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + + const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll( + '\\n', + '\n', + ).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; +} diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts new file mode 100644 index 0000000..224f0f6 --- /dev/null +++ b/lib/utils/misc.ts @@ -0,0 +1,301 @@ +let BASE_URL = typeof window !== 'undefined' && window.location + ? `${window.location.protocol}//${window.location.host}` + : ''; + +if (typeof Deno !== 'undefined') { + await import('std/dotenv/load.ts'); + + BASE_URL = Deno.env.get('BASE_URL') || ''; +} + +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 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'); +} + +export function escapeHtml(unsafe: string) { + return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function escapeXml(unsafe: string) { + return escapeHtml(unsafe).replaceAll('\r', ' '); +} + +export function generateRandomCode(length = 6) { + const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9 + + const codeDigits = Array.from({ length }).map(getRandomDigit); + + return codeDigits.join(''); +} + +export async function generateHash(value: string, algorithm: AlgorithmIdentifier) { + const hashedValueData = await crypto.subtle.digest( + algorithm, + new TextEncoder().encode(value), + ); + + const hashedValue = Array.from(new Uint8Array(hashedValueData)).map( + (byte) => byte.toString(16).padStart(2, '0'), + ).join(''); + + return hashedValue; +} + +export function splitArrayInChunks(array: T[], chunkLength: number) { + const chunks = []; + let chunkIndex = 0; + const arrayLength = array.length; + + while (chunkIndex < arrayLength) { + chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength)); + } + + return chunks; +} + +export function validateEmail(email: string) { + const trimmedEmail = (email || '').trim().toLocaleLowerCase(); + if (!trimmedEmail) { + return false; + } + + const requiredCharsNotInEdges = ['@', '.']; + return requiredCharsNotInEdges.every((char) => + trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char) + ); +} + +export function validateUrl(url: string) { + const trimmedUrl = (url || '').trim().toLocaleLowerCase(); + if (!trimmedUrl) { + return false; + } + + if (!trimmedUrl.includes('://')) { + return false; + } + + const protocolIndex = trimmedUrl.indexOf('://'); + const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3); + + if (!urlAfterProtocol) { + return false; + } + + return true; +} + +// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a +export async function concurrentPromises( + generators: (() => Promise)[], + maxConcurrency: number, +): Promise { + const iterator = generators.entries(); + + const results: T[] = []; + + let hasFailed = false; + + await Promise.all( + Array.from(Array(maxConcurrency), async () => { + for (const [index, promiseToExecute] of iterator) { + if (hasFailed) { + break; + } + try { + results[index] = await promiseToExecute(); + } catch (error) { + hasFailed = true; + throw error; + } + } + }), + ); + + return results; +} + +const MAX_RESPONSE_TIME_IN_MS = 10 * 1000; + +export async function fetchUrl(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(url, { + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlAsGooglebot(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + }, + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlWithProxy(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, { + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlWithRetries(url: string) { + try { + const text = await fetchUrl(url); + return text; + } catch (_error) { + try { + const text = await fetchUrlAsGooglebot(url); + return text; + } catch (_error) { + const text = await fetchUrlWithProxy(url); + return text; + } + } +} + +export function convertFormDataToObject(formData: FormData): Record { + return JSON.parse(JSON.stringify(Object.fromEntries(formData))); +} + +export function convertObjectToFormData(formDataObject: Record): FormData { + const formData = new FormData(); + + for (const key of Object.keys(formDataObject || {})) { + if (Array.isArray(formDataObject[key])) { + formData.append(key, formDataObject[key].join(',')); + } else { + formData.append(key, formDataObject[key]); + } + } + + return formData; +} + +function writeXmlTag(tagName: string, value: any, attributes?: Record) { + const attributesXml = attributes + ? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`) + .join(' ') + : ''; + + if (Array.isArray(value)) { + if (value.length === 0) { + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; + } + + const xmlLines: string[] = []; + + for (const valueItem of value) { + xmlLines.push(writeXmlTag(tagName, valueItem)); + } + + return xmlLines.join('\n'); + } + + if (typeof value === 'object') { + if (Object.keys(value).length === 0) { + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; + } + + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}`; + } + + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}`; +} + +export function convertObjectToDavXml(davObject: Record, isInitial = false): string { + const xmlLines: string[] = []; + + if (isInitial) { + xmlLines.push(``); + } + + for (const key of Object.keys(davObject)) { + if (key.endsWith('_attributes')) { + continue; + } + + xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`])); + } + + return xmlLines.join('\n'); +} + +function addLeadingZero(number: number) { + if (number < 10) { + return `0${number}`; + } + + return number.toString(); +} + +export function buildRFC822Date(dateString: string) { + const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + const timeStamp = Date.parse(dateString); + const date = new Date(timeStamp); + + const day = dayStrings[date.getDay()]; + const dayNumber = addLeadingZero(date.getUTCDate()); + const month = monthStrings[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`; + + return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`; +} + +export const capitalizeWord = (string: string) => { + return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`; +}; + +export function getRandomItem(items: Readonly>): T { + return items[Math.floor(Math.random() * items.length)]; +} diff --git a/lib/utils_test.ts b/lib/utils/misc_test.ts similarity index 99% rename from lib/utils_test.ts rename to lib/utils/misc_test.ts index b6f6615..453460f 100644 --- a/lib/utils_test.ts +++ b/lib/utils/misc_test.ts @@ -9,7 +9,7 @@ import { splitArrayInChunks, validateEmail, validateUrl, -} from './utils.ts'; +} from './misc.ts'; Deno.test('that escapeHtml works', () => { const tests: { input: string; expected: string }[] = [ diff --git a/main_test.ts b/main_test.ts index f523ea8..4f2cc42 100644 --- a/main_test.ts +++ b/main_test.ts @@ -31,14 +31,16 @@ Deno.test('Basic routes', async (testContext) => { assertEquals(response.status, 404); }); - // await testContext.step('#4 POST /', async () => { - // const formData = new FormData(); - // formData.append('text', 'Deno!'); - // const request = new Request('http://127.0.0.1/', { - // method: 'POST', - // body: formData, - // }); - // const response = await handler(request, CONN_INFO); - // assertEquals(response.status, 303); - // }); + await testContext.step('#4 POST /login', async () => { + const formData = new FormData(); + formData.append('email', 'user@example.com'); + const request = new Request('http://127.0.0.1/login', { + method: 'POST', + body: formData, + }); + const response = await handler(request, CONN_INFO); + const text = await response.text(); + assert(text.includes('Error: Password is too short')); + assertEquals(response.status, 200); + }); }); diff --git a/routes/_app.tsx b/routes/_app.tsx index 5496833..cd1c11d 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,7 +1,7 @@ import { PageProps } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { defaultDescription, defaultTitle } from '/lib/utils.ts'; +import { defaultDescription, defaultTitle } from '/lib/utils/misc.ts'; import Header from '/components/Header.tsx'; interface Data {} diff --git a/routes/api/calendar/import.tsx b/routes/api/calendar/import.tsx index dbfd926..4ca9d17 100644 --- a/routes/api/calendar/import.tsx +++ b/routes/api/calendar/import.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; import { createCalendarEvent, getCalendar, getCalendarEvents, updateCalendarEvent } from '/lib/data/calendar.ts'; interface Data {} diff --git a/routes/api/contacts/import.tsx b/routes/api/contacts/import.tsx index 7a3db30..3b407aa 100644 --- a/routes/api/contacts/import.tsx +++ b/routes/api/contacts/import.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Contact, FreshContextState } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts'; interface Data {} diff --git a/routes/api/news/import-feeds.tsx b/routes/api/news/import-feeds.tsx index 02f8949..4bfa72b 100644 --- a/routes/api/news/import-feeds.tsx +++ b/routes/api/news/import-feeds.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState, NewsFeed } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; import { fetchNewArticles } from '/crons/news.ts'; diff --git a/routes/calendar.tsx b/routes/calendar.tsx index 0f71cb6..1eb49bb 100644 --- a/routes/calendar.tsx +++ b/routes/calendar.tsx @@ -5,7 +5,7 @@ import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts'; import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx'; interface Data { - userCalendars: Pick[]; + userCalendars: Pick[]; userCalendarEvents: CalendarEvent[]; view: 'day' | 'week' | 'month'; startDate: string; diff --git a/routes/calendar/[calendarEventId].tsx b/routes/calendar/[calendarEventId].tsx index c3ef3de..6d48784 100644 --- a/routes/calendar/[calendarEventId].tsx +++ b/routes/calendar/[calendarEventId].tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { convertFormDataToObject } from '/lib/utils.ts'; +import { convertFormDataToObject } from '/lib/utils/misc.ts'; import { getCalendarEvent, getCalendars, updateCalendarEvent } from '/lib/data/calendar.ts'; import { getFormDataField } from '/lib/form-utils.tsx'; import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx'; diff --git a/routes/contacts/[contactId].tsx b/routes/contacts/[contactId].tsx index 1b5c467..49220f2 100644 --- a/routes/contacts/[contactId].tsx +++ b/routes/contacts/[contactId].tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts'; -import { convertFormDataToObject } from '/lib/utils.ts'; +import { convertFormDataToObject } from '/lib/utils/misc.ts'; import { getContact, updateContact } from '/lib/data/contacts.ts'; import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx'; import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx'; diff --git a/routes/dav/addressbooks.tsx b/routes/dav/addressbooks.tsx index dee10f6..9d9356f 100644 --- a/routes/dav/addressbooks.tsx +++ b/routes/dav/addressbooks.tsx @@ -1,7 +1,7 @@ import { Handler } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} diff --git a/routes/dav/addressbooks/contacts.tsx b/routes/dav/addressbooks/contacts.tsx index e34c062..9535467 100644 --- a/routes/dav/addressbooks/contacts.tsx +++ b/routes/dav/addressbooks/contacts.tsx @@ -2,14 +2,8 @@ import { Handler } from 'fresh/server.ts'; import { parse } from 'xml/mod.ts'; import { FreshContextState } from '/lib/types.ts'; -import { - buildRFC822Date, - convertObjectToDavXml, - DAV_RESPONSE_HEADER, - escapeHtml, - escapeXml, - formatContactToVCard, -} from '/lib/utils.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; +import { formatContactToVCard } from '/lib/utils/contacts.ts'; import { getAllContacts } from '/lib/data/contacts.ts'; import { createSessionCookie } from '/lib/auth.ts'; diff --git a/routes/dav/addressbooks/contacts/[contactId].vcf.tsx b/routes/dav/addressbooks/contacts/[contactId].vcf.tsx index 8897137..d56ac50 100644 --- a/routes/dav/addressbooks/contacts/[contactId].vcf.tsx +++ b/routes/dav/addressbooks/contacts/[contactId].vcf.tsx @@ -2,15 +2,8 @@ import { Handler } from 'fresh/server.ts'; import { parse } from 'xml/mod.ts'; import { Contact, FreshContextState } from '/lib/types.ts'; -import { - buildRFC822Date, - convertObjectToDavXml, - DAV_RESPONSE_HEADER, - escapeHtml, - escapeXml, - formatContactToVCard, - parseVCardFromTextContents, -} from '/lib/utils.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; +import { formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts'; import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts'; import { createSessionCookie } from '/lib/auth.ts'; diff --git a/routes/dav/calendars.tsx b/routes/dav/calendars.tsx index b7c6ce3..606e7e1 100644 --- a/routes/dav/calendars.tsx +++ b/routes/dav/calendars.tsx @@ -1,7 +1,7 @@ import { Handler } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; import { createSessionCookie } from '/lib/auth.ts'; import { getCalendars } from '/lib/data/calendar.ts'; diff --git a/routes/dav/calendars/[calendarId].tsx b/routes/dav/calendars/[calendarId].tsx index 37d6bd1..7248dd3 100644 --- a/routes/dav/calendars/[calendarId].tsx +++ b/routes/dav/calendars/[calendarId].tsx @@ -1,15 +1,8 @@ import { Handler } from 'fresh/server.ts'; import { Calendar, FreshContextState } from '/lib/types.ts'; -import { - buildRFC822Date, - convertObjectToDavXml, - DAV_RESPONSE_HEADER, - escapeHtml, - escapeXml, - formatCalendarEventsToVCalendar, - parseVCalendarFromTextContents, -} from '/lib/utils.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; +import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; import { getCalendar, getCalendarEvents } from '/lib/data/calendar.ts'; import { createSessionCookie } from '/lib/auth.ts'; diff --git a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx b/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx index e57016a..f6f0bc6 100644 --- a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx +++ b/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx @@ -1,16 +1,9 @@ import { Handler } from 'fresh/server.ts'; import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { - buildRFC822Date, - convertObjectToDavXml, - DAV_RESPONSE_HEADER, - escapeHtml, - escapeXml, - formatCalendarEventsToVCalendar, - parseVCalendarFromTextContents, -} from '/lib/utils.ts'; -import { getCalendar, getCalendarEvent, getCalendarEvents, updateCalendarEvent } from '/lib/data/calendar.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; +import { formatCalendarEventsToVCalendar } from '/lib/utils/calendar.ts'; +import { getCalendar, getCalendarEvent, updateCalendarEvent } from '/lib/data/calendar.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} diff --git a/routes/dav/files.tsx b/routes/dav/files.tsx index 9f3872e..c97c47e 100644 --- a/routes/dav/files.tsx +++ b/routes/dav/files.tsx @@ -1,7 +1,7 @@ import { Handler } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} diff --git a/routes/dav/index.tsx b/routes/dav/index.tsx index 33366ac..6a318d1 100644 --- a/routes/dav/index.tsx +++ b/routes/dav/index.tsx @@ -1,7 +1,7 @@ import { Handler } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} diff --git a/routes/dav/principals.tsx b/routes/dav/principals.tsx index e2d4fff..50f0a0b 100644 --- a/routes/dav/principals.tsx +++ b/routes/dav/principals.tsx @@ -1,7 +1,7 @@ import { Handler } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils.ts'; +import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils/misc.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} diff --git a/routes/login.tsx b/routes/login.tsx index d8b25dc..7f855f6 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,6 +1,6 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts'; +import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { createVerificationCode, getUserByEmail, updateUser, validateVerificationCode } from '/lib/data/user.ts'; diff --git a/routes/settings.tsx b/routes/settings.tsx index 4b7ff69..a62a5fa 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -9,7 +9,7 @@ import { updateUser, validateVerificationCode, } from '/lib/data/user.ts'; -import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils.ts'; +import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { getFormDataField } from '/lib/form-utils.tsx'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import Settings, { Action, actionWords } from '/islands/Settings.tsx'; diff --git a/routes/signup.tsx b/routes/signup.tsx index d416e78..2542959 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -1,6 +1,6 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts'; +import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { createUser, createVerificationCode, getUserByEmail } from '/lib/data/user.ts';