diff --git a/README.md b/README.md index 34ace24..cca6455 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Just push to the `main` branch. ## How does Contacts/CardDav and Calendar/CalDav work? -CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The clients are not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). +CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and for CalDav is not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s //collections/collection-root// //collections/collection-root//`). diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index 2018fb9..130a7c8 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -20,7 +20,7 @@ const config: PartialDeep = { // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) // }, // core: { - // enabledApps: ['news', 'notes', 'photos', 'expenses'], // dashboard and files cannot be disabled + // enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts], // dashboard and files cannot be disabled // }, // visuals: { // title: 'My own cloud', diff --git a/components/Header.tsx b/components/Header.tsx index 19e701b..6258e05 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -56,6 +56,12 @@ export default function Header({ route, user, enabledApps }: Data) { label: 'Expenses', } : null, + enabledApps.includes('contacts') + ? { + url: '/contacts', + label: 'Contacts', + } + : null, ]; const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[]; @@ -77,6 +83,10 @@ export default function Header({ route, user, enabledApps }: Data) { pageLabel = 'Budgets & Expenses'; } + if (route.startsWith('/contacts')) { + pageLabel = 'Contacts'; + } + return ( <> diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index ff7d57a..c227913 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -48,7 +48,6 @@ interface MainFilesProps { initialPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCardDavEnabled?: boolean; isCalDavEnabled?: boolean; fileShareId?: string; } @@ -60,7 +59,6 @@ export default function MainFiles( initialPath, baseUrl, isFileSharingAllowed, - isCardDavEnabled, isCalDavEnabled, fileShareId, }: MainFilesProps, @@ -890,15 +888,6 @@ export default function MainFiles( ) : null} - {!fileShareId && isCardDavEnabled - ? ( -
- CardDav URL:{' '} - {baseUrl}/carddav -
- ) - : null} - {!fileShareId && isCalDavEnabled ? (
diff --git a/deno.json b/deno.json index 5b98d2b..76419da 100644 --- a/deno.json +++ b/deno.json @@ -44,13 +44,14 @@ "chart.js": "https://esm.sh/chart.js@4.4.9/auto", "otpauth": "https://esm.sh/otpauth@9.4.0", "qrcode": "https://esm.sh/qrcode@1.5.4", - "openid-client": "https://esm.sh/openid-client@6.5.0", - "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", - "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", - "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0", + "openid-client": "https://esm.sh/openid-client@6.6.3", + "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.2", + "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.2/helpers", + "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.2", "tailwindcss": "npm:tailwindcss@3.4.17", "tailwindcss/": "npm:/tailwindcss@3.4.17/", "tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", - "nodemailer": "npm:nodemailer@7.0.3" + "nodemailer": "npm:nodemailer@7.0.5", + "tsdav": "https://raw.githubusercontent.com/sunsama/tsdav/cc1c5a09b64c87bbee7e5f171cfcb6748e99469e/dist/tsdav.js" } } diff --git a/docker-compose.yml b/docker-compose.yml index 977fac9..e3bd5ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: website: - image: ghcr.io/bewcloud/bewcloud:v2.3.1 + image: ghcr.io/bewcloud/bewcloud:v2.4.0 restart: always ports: - 127.0.0.1:8000:8000 diff --git a/fresh.gen.ts b/fresh.gen.ts index 1369160..1e21d47 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -15,6 +15,13 @@ import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/m import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts'; import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts'; import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts'; +import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx'; +import * as $api_contacts_add from './routes/api/contacts/add.tsx'; +import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx'; +import * as $api_contacts_delete from './routes/api/contacts/delete.tsx'; +import * as $api_contacts_get_addressbooks from './routes/api/contacts/get-addressbooks.tsx'; +import * as $api_contacts_get from './routes/api/contacts/get.tsx'; +import * as $api_contacts_import from './routes/api/contacts/import.tsx'; import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx'; import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx'; import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx'; @@ -49,6 +56,8 @@ import * as $api_news_refresh_articles from './routes/api/news/refresh-articles. import * as $api_notes_save from './routes/api/notes/save.tsx'; import * as $caldav from './routes/caldav.tsx'; import * as $carddav from './routes/carddav.tsx'; +import * as $contacts from './routes/contacts.tsx'; +import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx'; import * as $dashboard from './routes/dashboard.tsx'; import * as $dav from './routes/dav.tsx'; import * as $expenses from './routes/expenses.tsx'; @@ -73,6 +82,8 @@ import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx'; import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx'; +import * as $contacts_Contacts from './islands/contacts/Contacts.tsx'; +import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx'; @@ -99,6 +110,13 @@ const manifest = { './routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete, './routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify, './routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup, + './routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook, + './routes/api/contacts/add.tsx': $api_contacts_add, + './routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook, + './routes/api/contacts/delete.tsx': $api_contacts_delete, + './routes/api/contacts/get-addressbooks.tsx': $api_contacts_get_addressbooks, + './routes/api/contacts/get.tsx': $api_contacts_get, + './routes/api/contacts/import.tsx': $api_contacts_import, './routes/api/dashboard/save-links.tsx': $api_dashboard_save_links, './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, './routes/api/expenses/add-budget.tsx': $api_expenses_add_budget, @@ -133,6 +151,8 @@ const manifest = { './routes/api/notes/save.tsx': $api_notes_save, './routes/caldav.tsx': $caldav, './routes/carddav.tsx': $carddav, + './routes/contacts.tsx': $contacts, + './routes/contacts/[contactId].tsx': $contacts_contactId_, './routes/dashboard.tsx': $dashboard, './routes/dav.tsx': $dav, './routes/expenses.tsx': $expenses, @@ -159,6 +179,8 @@ const manifest = { './islands/Settings.tsx': $Settings, './islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings, './islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin, + './islands/contacts/Contacts.tsx': $contacts_Contacts, + './islands/contacts/ViewContact.tsx': $contacts_ViewContact, './islands/dashboard/Links.tsx': $dashboard_Links, './islands/dashboard/Notes.tsx': $dashboard_Notes, './islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper, diff --git a/islands/contacts/Contacts.tsx b/islands/contacts/Contacts.tsx new file mode 100644 index 0000000..0dca3e7 --- /dev/null +++ b/islands/contacts/Contacts.tsx @@ -0,0 +1,609 @@ +import { useSignal } from '@preact/signals'; + +import { AddressBook, Contact } from '/lib/models/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'; +import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; +import { + RequestBody as AddAddressBookRequestBody, + ResponseBody as AddAddressBookResponseBody, +} from '/routes/api/contacts/add-addressbook.tsx'; +import { + RequestBody as DeleteAddressBookRequestBody, + ResponseBody as DeleteAddressBookResponseBody, +} from '/routes/api/contacts/delete-addressbook.tsx'; + +interface ContactsProps { + initialAddressBookId: string; + initialContacts: Contact[]; + initialAddressBooks: AddressBook[]; + page: number; + contactsCount: number; + baseUrl: string; + search?: string; +} + +const CONTACTS_PER_PAGE_COUNT = 10; // This helps make the UI a bit faster (less stuff to render) + +export default function Contacts( + { initialContacts, initialAddressBooks, page, contactsCount, search, initialAddressBookId, baseUrl }: ContactsProps, +) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isExporting = useSignal(false); + const isImporting = useSignal(false); + const contacts = useSignal(initialContacts); + const addressBooks = useSignal(initialAddressBooks); + const selectedAddressBookId = useSignal(initialAddressBookId); + const selectedAddressBookName = useSignal( + initialAddressBooks.find((addressBook) => addressBook.uid === initialAddressBookId)?.displayName || 'Address Book', + ); + const isAddressBooksDropdownOpen = useSignal(false); + const isOptionsDropdownOpen = useSignal(false); + + async function onClickAddContact() { + if (isAdding.value) { + return; + } + + const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim(); + + if (!firstName) { + alert('A first name is required for a new contact!'); + return; + } + + const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim(); + + isAdding.value = true; + + try { + const requestBody: AddRequestBody = { firstName, lastName, addressBookId: selectedAddressBookId.value }; + const response = await fetch(`/api/contacts/add`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to add contact. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as AddResponseBody; + + if (!result.success) { + throw new Error('Failed to add contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function toggleOptionsDropdown() { + isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value; + } + + async function onClickAddAddressBook() { + if (isAdding.value) { + return; + } + + const name = (prompt(`What's the **name** for the new address book?`) || '').trim(); + + if (!name) { + alert('A name is required for a new address book!'); + return; + } + + isAdding.value = true; + isAddressBooksDropdownOpen.value = false; + + try { + const requestBody: AddAddressBookRequestBody = { name }; + const response = await fetch(`/api/contacts/add-addressbook`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to add address book. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as AddAddressBookResponseBody; + + if (!result.success) { + throw new Error('Failed to add address book!'); + } + + addressBooks.value = [...result.addressBooks]; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function toggleAddressBooksDropdown() { + isAddressBooksDropdownOpen.value = !isAddressBooksDropdownOpen.value; + } + + function onClickSelectAddressBook(addressBookId: string) { + selectedAddressBookId.value = addressBookId; + selectedAddressBookName.value = + addressBooks.value.find((addressBook) => addressBook.uid === addressBookId)?.displayName || + 'Address Book'; + isAddressBooksDropdownOpen.value = false; + window.location.href = `/contacts?addressBookId=${addressBookId}`; + } + + async function onClickDeleteAddressBook(addressBookId: string) { + if (confirm('Are you sure you want to delete this address book?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteAddressBookRequestBody = { addressBookId }; + const response = await fetch(`/api/contacts/delete-addressbook`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to delete address book. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as DeleteAddressBookResponseBody; + + if (!result.success) { + throw new Error('Failed to delete address book!'); + } + + addressBooks.value = [...result.addressBooks]; + selectedAddressBookId.value = ''; + selectedAddressBookName.value = ''; + window.location.href = `/contacts`; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + async function onClickDeleteContact(contactId: string) { + if (confirm('Are you sure you want to delete this contact?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { contactId, addressBookId: selectedAddressBookId.value }; + const response = await fetch(`/api/contacts/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to delete contact. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + function onClickImportVCard() { + isOptionsDropdownOpen.value = false; + + if (isImporting.value) { + return; + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + + fileInput.onchange = (event) => { + const files = (event.target as HTMLInputElement)?.files!; + const file = files[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = async (fileRead) => { + const importFileContents = fileRead.target?.result; + + if (!importFileContents || isImporting.value) { + return; + } + + isImporting.value = true; + + try { + const vCards = importFileContents!.toString(); + + const requestBody: ImportRequestBody = { addressBookId: selectedAddressBookId.value, vCards }; + const response = await fetch(`/api/contacts/import`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to import contact. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as ImportResponseBody; + + if (!result.success) { + throw new Error('Failed to import contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isImporting.value = false; + }; + + reader.readAsText(file, 'UTF-8'); + }; + } + + async function onClickExportVCard() { + isOptionsDropdownOpen.value = false; + + if (isExporting.value) { + return; + } + + isExporting.value = true; + + const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf'] + .join(''); + + try { + const requestBody: GetRequestBody = { addressBookId: selectedAddressBookId.value }; + const response = await fetch(`/api/contacts/get`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to export contact. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as GetResponseBody; + + if (!result.success) { + throw new Error('Failed to get contact!'); + } + + const exportContents = result.contacts.map((contact) => contact.data).join('\n\n'); + + // Add content-type + const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join(''); + + // Download the file + const data = vCardContent; + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + } catch (error) { + console.error(error); + } + + isExporting.value = false; + } + + const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT); + const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1); + + return ( + <> +
+
+
+ +
+
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ +
+ + + + + + + + + + {contacts.value.map((contact) => ( + + + + + + ))} + {contacts.value.length === 0 + ? ( + + + + ) + : null} + +
First NameLast Name
+ + {contact.firstName} + + + {contact.lastName} + + +
+
+
No contacts to show
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} + +
+ + {pagesCount > 0 + ? ( +
+ +
+ ) + : null} + +
+ CardDAV URL:{' '} + {baseUrl}/carddav +
+ + ); +} diff --git a/islands/contacts/ViewContact.tsx b/islands/contacts/ViewContact.tsx new file mode 100644 index 0000000..3335573 --- /dev/null +++ b/islands/contacts/ViewContact.tsx @@ -0,0 +1,204 @@ +import { useSignal } from '@preact/signals'; + +import { Contact } from '/lib/models/contacts.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'; + +interface ViewContactProps { + addressBookId: string; + initialContact: Contact; + formData: Record; + error?: string; + notice?: string; +} + +export function formFields(contact: Contact, updateType: 'raw' | 'ui') { + const fields: FormField[] = [ + { + name: 'update-type', + label: 'Update type', + type: 'hidden', + value: updateType, + readOnly: true, + }, + ]; + + if (updateType === 'ui') { + fields.push({ + name: 'first_name', + label: 'First name', + type: 'text', + placeholder: 'John', + value: contact.firstName, + required: true, + }, { + name: 'last_name', + label: 'Last name', + type: 'text', + placeholder: 'Doe', + value: contact.lastName, + required: false, + }, { + name: 'main_phone', + label: 'Main phone', + type: 'tel', + placeholder: '+44 0000 111 2222', + value: contact.phone, + required: false, + }, { + name: 'main_email', + label: 'Main email', + type: 'email', + placeholder: 'john.doe@example.com', + value: contact.email, + required: false, + }, { + name: 'notes', + label: 'Notes', + type: 'textarea', + placeholder: 'Some notes...', + value: contact.notes, + required: false, + }); + } else if (updateType === 'raw') { + fields.push({ + name: 'vcard', + label: 'Raw vCard', + type: 'textarea', + placeholder: 'Raw vCard...', + value: contact.data, + description: + 'This is the raw vCard for this contact. Use this to manually update the contact _if_ you know what you are doing.', + rows: '10', + }); + } + + return fields; +} + +export default function ViewContact( + { initialContact, formData: formDataObject, error, notice, addressBookId }: ViewContactProps, +) { + const isDeleting = useSignal(false); + const contact = useSignal(initialContact); + + const formData = convertObjectToFormData(formDataObject); + + async function onClickDeleteContact() { + if (confirm('Are you sure you want to delete this contact?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { contactId: contact.value.uid!, addressBookId }; + const response = await fetch(`/api/contacts/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to delete contact. ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete contact!'); + } + + window.location.href = '/contacts'; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+ View contacts +
+ +
+
+ +
+ {error + ? ( +
+

Failed to update!

+

{error}

+
+ ) + : null} + {notice + ? ( +
+

Success!

+

{notice}

+
+ ) + : null} + +
+ {formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData))} + +
+ +
+
+ +
+ +
+ + Edit Raw vCard{' '} + + Expand + + + +
+ {formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData))} + +
+ +
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {!isDeleting.value ? <>  : null} + +
+ + ); +} diff --git a/islands/files/FilesWrapper.tsx b/islands/files/FilesWrapper.tsx index b128d3b..8024e24 100644 --- a/islands/files/FilesWrapper.tsx +++ b/islands/files/FilesWrapper.tsx @@ -7,7 +7,6 @@ interface FilesWrapperProps { initialPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCardDavEnabled?: boolean; isCalDavEnabled?: boolean; fileShareId?: string; } @@ -20,7 +19,6 @@ export default function FilesWrapper( initialPath, baseUrl, isFileSharingAllowed, - isCardDavEnabled, isCalDavEnabled, fileShareId, }: FilesWrapperProps, @@ -32,7 +30,6 @@ export default function FilesWrapper( initialPath={initialPath} baseUrl={baseUrl} isFileSharingAllowed={isFileSharingAllowed} - isCardDavEnabled={isCardDavEnabled} isCalDavEnabled={isCalDavEnabled} fileShareId={fileShareId} /> diff --git a/lib/config.ts b/lib/config.ts index 1dfe177..46afaa7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -24,7 +24,7 @@ export class AppConfig { allowPublicSharing: false, }, core: { - enabledApps: ['news', 'notes', 'photos', 'expenses'], + enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'], }, visuals: { title: '', @@ -83,6 +83,14 @@ export class AppConfig { ...config.email, ...configFromFile.email, }, + contacts: { + ...config.contacts, + ...configFromFile.contacts, + }, + calendar: { + ...config.calendar, + ...configFromFile.calendar, + }, }; console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n'); diff --git a/lib/models/contacts.ts b/lib/models/contacts.ts new file mode 100644 index 0000000..0ccca51 --- /dev/null +++ b/lib/models/contacts.ts @@ -0,0 +1,203 @@ +import { createDAVClient } from 'tsdav'; + +import { AppConfig } from '/lib/config.ts'; +import { parseVCard } from '/lib/utils/contacts.ts'; + +interface DAVObject extends Record { + data?: string; + displayName?: string; + ctag?: string; + url: string; + uid?: string; +} + +export interface Contact extends DAVObject { + firstName?: string; + lastName?: string; + middleNames?: string[]; + title?: string; + email?: string; + phone?: string; + notes?: string; +} + +export interface AddressBook extends DAVObject {} + +const contactsConfig = await AppConfig.getContactsConfig(); + +async function getClient(userId: string) { + const client = await createDAVClient({ + serverUrl: contactsConfig.cardDavUrl, + credentials: {}, + authMethod: 'Custom', + // deno-lint-ignore require-await + authFunction: async () => { + return { + 'X-Remote-User': userId, + }; + }, + fetchOptions: { + timeout: 15_000, + }, + defaultAccountType: 'carddav', + rootUrl: `${contactsConfig.cardDavUrl}/`, + principalUrl: `${contactsConfig.cardDavUrl}/${userId}/`, + homeUrl: `${contactsConfig.cardDavUrl}/${userId}/`, + }); + + return client; +} + +export class ContactModel { + static async list( + userId: string, + addressBookId: string, + ): Promise { + const client = await getClient(userId); + + const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`; + + const davContacts: DAVObject[] = await client.fetchVCards({ + addressBook: { + url: addressBookUrl, + }, + }); + + const contacts: Contact[] = davContacts.map((davContact) => { + return { + ...davContact, + ...parseVCard(davContact.data || '')[0], + }; + }); + + return contacts; + } + + static async get( + userId: string, + addressBookId: string, + contactId: string, + ): Promise { + const contacts = await this.list(userId, addressBookId); + + return contacts.find((contact) => contact.uid === contactId); + } + + static async create( + userId: string, + addressBookId: string, + contactId: string, + vCard: string, + ): Promise { + const client = await getClient(userId); + + const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`; + + await client.createVCard({ + addressBook: { + url: addressBookUrl, + }, + vCardString: vCard, + filename: `${contactId}.vcf`, + }); + } + + static async update( + userId: string, + contactUrl: string, + vCard: string, + ): Promise { + const client = await getClient(userId); + + await client.updateVCard({ + vCard: { + url: contactUrl, + data: vCard, + }, + }); + } + + static async delete( + userId: string, + contactUrl: string, + ): Promise { + const client = await getClient(userId); + + await client.deleteVCard({ + vCard: { + url: contactUrl, + }, + }); + } + + static async listAddressBooks( + userId: string, + ): Promise { + const client = await getClient(userId); + + const davAddressBooks: DAVObject[] = await client.fetchAddressBooks(); + + const addressBooks: AddressBook[] = davAddressBooks.map((davAddressBook) => { + const uid = davAddressBook.url.split('/').filter(Boolean).pop()!; + + return { + ...davAddressBook, + uid, + }; + }); + + return addressBooks; + } + + static async createAddressBook( + userId: string, + name: string, + ): Promise { + const addressBookId = crypto.randomUUID(); + const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`; + + // For some reason this sends invalid XML + // await client.makeCollection({ + // url: addressBookUrl, + // props: { + // displayName: name, + // }, + // }); + + // Make "manual" request (https://www.rfc-editor.org/rfc/rfc6352.html#page-14) + const xmlBody = ` + + + + ${encodeURIComponent(name)} + + + + + + +`; + + await fetch(addressBookUrl, { + method: 'MKCOL', + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'X-Remote-User': userId, + }, + body: xmlBody, + }); + } + + static async deleteAddressBook( + userId: string, + addressBookId: string, + ): Promise { + const client = await getClient(userId); + + const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`; + + await client.deleteObject({ + url: addressBookUrl, + }); + } +} diff --git a/lib/types.ts b/lib/types.ts index aff5358..10ea659 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -147,7 +147,7 @@ export const currencyMap = new Map([ export type PartialDeep = (T extends (infer U)[] ? PartialDeep[] : { [P in keyof T]?: PartialDeep }) | T; -export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses'; +export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts'; export interface Config { auth: { diff --git a/lib/utils/contacts.ts b/lib/utils/contacts.ts new file mode 100644 index 0000000..a973d0f --- /dev/null +++ b/lib/utils/contacts.ts @@ -0,0 +1,238 @@ +import { Contact } from '/lib/models/contacts.ts'; + +export function getIdFromVCard(vCard: string): string { + const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean); + + // Loop through every line and find the UID line + for (const line of lines) { + if (line.startsWith('UID:')) { + const uid = line.replace('UID:', ''); + return uid.trim(); + } + } + + return crypto.randomUUID(); +} + +export function splitTextIntoVCards(text: string): string[] { + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + const vCards: string[] = []; + const currentVCard: string[] = []; + + for (const line of lines) { + currentVCard.push(line); + + if (line.startsWith('END:VCARD')) { + vCards.push(currentVCard.join('\n')); + currentVCard.length = 0; + } + } + + return vCards; +} + +export function generateVCard(contactId: string, firstName: string, lastName?: string): string { + const vCardText = `BEGIN:VCARD +VERSION:4.0 +N:${getSafelyEscapedTextForVCard(lastName || '')};${getSafelyEscapedTextForVCard(firstName)}; +FN:${getSafelyEscapedTextForVCard(firstName)} ${getSafelyEscapedTextForVCard(lastName || '')} +UID:${getSafelyEscapedTextForVCard(contactId)} +END:VCARD`; + + return vCardText; +} + +export function updateVCard( + vCard: string, + { firstName, lastName, email, phone, notes }: { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + notes?: string; + }, +): string { + const lines = vCard.split('\n').map((line) => line.trim()).filter(Boolean); + + let replacedName = false; + let replacedFormattedName = false; + let replacedEmail = false; + let replacedPhone = false; + let replacedNotes = false; + + const updatedVCardLines = lines.map((line) => { + if (line.startsWith('N:') && firstName && !replacedName) { + replacedName = true; + return `N:${getSafelyEscapedTextForVCard(lastName || '')};${getSafelyEscapedTextForVCard(firstName)};`; + } + + if (line.startsWith('FN:') && firstName && !replacedFormattedName) { + replacedFormattedName = true; + return `FN:${getSafelyEscapedTextForVCard(firstName)} ${getSafelyEscapedTextForVCard(lastName || '')}`; + } + + if ((line.startsWith('EMAIL:') || line.startsWith('EMAIL;')) && email && !replacedEmail) { + replacedEmail = true; + return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(email)); + } + + if ((line.startsWith('TEL:') || line.startsWith('TEL;')) && phone && !replacedPhone) { + replacedPhone = true; + return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(phone)); + } + + if (line.startsWith('NOTE:') && notes && !replacedNotes) { + replacedNotes = true; + return line.replace(line.split(':')[1], getSafelyEscapedTextForVCard(notes.replaceAll('\r', ''))); + } + + return line; + }); + + // Remove last line with END:VCARD + const endLineIndex = updatedVCardLines.findIndex((line) => line.startsWith('END:VCARD')); + updatedVCardLines.splice(endLineIndex, 1); + + if (!replacedEmail && email) { + updatedVCardLines.push(`EMAIL;TYPE=HOME:${getSafelyEscapedTextForVCard(email)}`); + } + if (!replacedPhone && phone) { + updatedVCardLines.push(`TEL;TYPE=HOME:${getSafelyEscapedTextForVCard(phone)}`); + } + if (!replacedNotes && notes) { + updatedVCardLines.push(`NOTE:${getSafelyEscapedTextForVCard(notes.replaceAll('\r', ''))}`); + } + + updatedVCardLines.push('END:VCARD'); + + const updatedVCard = updatedVCardLines.map((line) => line.trim()).filter(Boolean).join('\n'); + + return updatedVCard; +} + +function getSafelyEscapedTextForVCard(text: string) { + return text.replaceAll('\n', '\\n').replaceAll(',', '\\,'); +} + +function getSafelyUnescapedTextFromVCard(text: string): string { + return text.replaceAll('\\n', '\n').replaceAll('\\,', ','); +} + +type VCardVersion = '2.1' | '3.0' | '4.0'; + +export function parseVCard(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.uid = 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.firstName = firstName; + partialContact.lastName = lastName; + partialContact.middleNames = middleNames; + partialContact.title = title; + + continue; + } + + if (line.startsWith('NOTE:')) { + const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || ''); + + partialContact.notes = notes; + + continue; + } + + if ((line.includes('TEL;') || line.includes('TEL:')) && !partialContact.phone) { + const phoneInfo = line.split('TEL;')[1] || line.split('TEL')[1] || ''; + const phoneNumber = phoneInfo.split(':')[1] || ''; + // const label = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); + + if (!phoneNumber) { + continue; + } + + partialContact.phone = phoneNumber; + + continue; + } + + if ((line.includes('EMAIL;') || line.includes('EMAIL:')) && !partialContact.email) { + const emailInfo = line.split('EMAIL;')[1] || line.split('EMAIL')[1] || ''; + const emailAddress = emailInfo.split(':')[1] || ''; + // const label = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); + + if (!emailAddress) { + continue; + } + + partialContact.email = emailAddress; + + continue; + } + } + + return partialContacts; +} diff --git a/lib/utils/contacts_test.ts b/lib/utils/contacts_test.ts new file mode 100644 index 0000000..ae5ad0b --- /dev/null +++ b/lib/utils/contacts_test.ts @@ -0,0 +1,503 @@ +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { assertMatch } from 'std/assert/assert_match.ts'; + +import { generateVCard, getIdFromVCard, parseVCard, splitTextIntoVCards, updateVCard } from './contacts.ts'; + +Deno.test('that getIdFromVCard works', () => { + const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [ + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:12345-abcde-67890 +FN:John Doe +END:VCARD`, + expected: '12345-abcde-67890', + }, + { + input: `BEGIN:VCARD +VERSION:3.0 +FN:Jane Smith +UID:jane-smith-uuid +EMAIL:jane@example.com +END:VCARD`, + expected: 'jane-smith-uuid', + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +FN:No UID Contact +EMAIL:nouid@example.com +END:VCARD`, + shouldBeUUID: true, + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID: spaced-uid +FN:Spaced UID +END:VCARD`, + expected: 'spaced-uid', + }, + ]; + + for (const test of tests) { + const output = getIdFromVCard(test.input); + if (test.expected) { + assertEquals(output, test.expected); + } else if (test.shouldBeUUID) { + // Check that it's a valid UUID format + assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + } + } +}); + +Deno.test('that splitTextIntoVCards works', () => { + const tests: { input: string; expected: string[] }[] = [ + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:1 +FN:John Doe +END:VCARD +BEGIN:VCARD +VERSION:4.0 +UID:2 +FN:Jane Smith +END:VCARD`, + expected: [ + `BEGIN:VCARD +VERSION:4.0 +UID:1 +FN:John Doe +END:VCARD`, + `BEGIN:VCARD +VERSION:4.0 +UID:2 +FN:Jane Smith +END:VCARD`, + ], + }, + { + input: `BEGIN:VCARD +VERSION:3.0 +FN:Single Contact +EMAIL:single@example.com +END:VCARD`, + expected: [ + `BEGIN:VCARD +VERSION:3.0 +FN:Single Contact +EMAIL:single@example.com +END:VCARD`, + ], + }, + { + input: '', + expected: [], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +FN:Incomplete Contact`, + expected: [], + }, + ]; + + for (const test of tests) { + const output = splitTextIntoVCards(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that generateVCard works', () => { + const tests: { input: { contactId: string; firstName: string; lastName?: string }; expected: string }[] = [ + { + input: { contactId: 'test-123', firstName: 'John', lastName: 'Doe' }, + expected: `BEGIN:VCARD +VERSION:4.0 +N:Doe;John; +FN:John Doe +UID:test-123 +END:VCARD`, + }, + { + input: { contactId: 'single-name', firstName: 'Madonna' }, + expected: `BEGIN:VCARD +VERSION:4.0 +N:;Madonna; +FN:Madonna +UID:single-name +END:VCARD`, + }, + { + input: { contactId: 'special-chars', firstName: 'John,Test', lastName: 'Doe\nSmith' }, + expected: `BEGIN:VCARD +VERSION:4.0 +N:Doe\\nSmith;John\\,Test; +FN:John\\,Test Doe\\nSmith +UID:special-chars +END:VCARD`, + }, + ]; + + for (const test of tests) { + const output = generateVCard(test.input.contactId, test.input.firstName, test.input.lastName); + assertEquals(output, test.expected); + } +}); + +Deno.test('that updateVCard works', () => { + const tests: { + input: { + vCard: string; + updates: { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + notes?: string; + }; + }; + expected: string; + }[] = [ + { + input: { + vCard: `BEGIN:VCARD +VERSION:4.0 +UID:test-123 +N:Doe;John; +FN:John Doe +END:VCARD`, + updates: { firstName: 'Jane', lastName: 'Smith' }, + }, + expected: `BEGIN:VCARD +VERSION:4.0 +UID:test-123 +N:Smith;Jane; +FN:Jane Smith +END:VCARD`, + }, + { + input: { + vCard: `BEGIN:VCARD +VERSION:4.0 +UID:test-456 +N:Doe;John; +FN:John Doe +EMAIL:old@example.com +TEL:+1234567890 +END:VCARD`, + updates: { email: 'new@example.com', phone: '+9876543210' }, + }, + expected: `BEGIN:VCARD +VERSION:4.0 +UID:test-456 +N:Doe;John; +FN:John Doe +EMAIL:new@example.com +TEL:+9876543210 +END:VCARD`, + }, + { + input: { + vCard: `BEGIN:VCARD +VERSION:4.0 +UID:test-789 +N:Doe;John; +FN:John Doe +END:VCARD`, + updates: { email: 'added@example.com', phone: '+1111111111', notes: 'Test notes' }, + }, + expected: `BEGIN:VCARD +VERSION:4.0 +UID:test-789 +N:Doe;John; +FN:John Doe +EMAIL;TYPE=HOME:added@example.com +TEL;TYPE=HOME:+1111111111 +NOTE:Test notes +END:VCARD`, + }, + { + input: { + vCard: `BEGIN:VCARD +VERSION:4.0 +UID:test-special +N:Doe;John; +FN:John Doe +NOTE:Old notes +END:VCARD`, + updates: { notes: 'New notes\nwith newlines, and commas' }, + }, + expected: `BEGIN:VCARD +VERSION:4.0 +UID:test-special +N:Doe;John; +FN:John Doe +NOTE:New notes\\nwith newlines\\, and commas +END:VCARD`, + }, + { + input: { + vCard: `BEGIN:VCARD +VERSION:4.0 +UID:test-carriage +N:Doe;John; +FN:John Doe +END:VCARD`, + updates: { notes: 'Notes with\r\ncarriage returns' }, + }, + expected: `BEGIN:VCARD +VERSION:4.0 +UID:test-carriage +N:Doe;John; +FN:John Doe +NOTE:Notes with\\ncarriage returns +END:VCARD`, + }, + ]; + + for (const test of tests) { + const output = updateVCard(test.input.vCard, test.input.updates); + assertEquals(output, test.expected); + } +}); + +Deno.test('that parseVCard works', () => { + const tests: { + input: string; + expected: Array<{ + uid?: string; + firstName?: string; + lastName?: string; + middleNames?: string[]; + title?: string; + email?: string; + phone?: string; + notes?: string; + }>; + }[] = [ + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:test-123 +N:Doe;John;Middle;Jr +FN:John Middle Doe Jr +EMAIL;TYPE=HOME:john@example.com +TEL;TYPE=HOME:+1234567890 +NOTE:Test contact notes +END:VCARD`, + expected: [{ + uid: 'test-123', + firstName: 'John', + lastName: 'Doe', + middleNames: ['Middle'], + title: 'Jr', + email: 'john@example.com', + phone: '+1234567890', + notes: 'Test contact notes', + }], + }, + { + input: `BEGIN:VCARD +VERSION:3.0 +UID:test-456 +N:Smith;Jane;; +FN:Jane Smith +EMAIL:jane@example.com +TEL:+9876543210 +END:VCARD`, + expected: [{ + uid: 'test-456', + firstName: 'Jane', + lastName: 'Smith', + middleNames: [], + title: '', + email: 'jane@example.com', + phone: '+9876543210', + }], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:multi-1 +N:Doe;John; +FN:John Doe +END:VCARD +BEGIN:VCARD +VERSION:4.0 +UID:multi-2 +N:Smith;Jane; +FN:Jane Smith +EMAIL;PREF=1:jane@example.com +END:VCARD`, + expected: [ + { + uid: 'multi-1', + firstName: 'John', + lastName: 'Doe', + middleNames: [], + title: '', + }, + { + uid: 'multi-2', + firstName: 'Jane', + lastName: 'Smith', + middleNames: [], + title: '', + email: 'jane@example.com', + }, + ], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:escaped-contact +N:Test;Contact; +FN:Contact Test +NOTE:Notes with\\nescaped newlines\\, and commas +END:VCARD`, + expected: [{ + uid: 'escaped-contact', + firstName: 'Contact', + lastName: 'Test', + middleNames: [], + title: '', + notes: 'Notes with\nescaped newlines, and commas', + }], + }, + { + input: `BEGIN:VCARD +VERSION:2.1 +UID:version-21 +N:Old;Format; +FN:Format Old +EMAIL:old@example.com +TEL:+1111111111 +END:VCARD`, + expected: [{ + uid: 'version-21', + firstName: 'Format', + lastName: 'Old', + middleNames: [], + title: '', + email: 'old@example.com', + phone: '+1111111111', + }], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:email-variations +N:Test;Email; +FN:Email Test +EMAIL:direct@example.com +EMAIL;TYPE=WORK:work@example.com +TEL:+1234567890 +TEL;TYPE=WORK:+9876543210 +END:VCARD`, + expected: [{ + uid: 'email-variations', + firstName: 'Email', + lastName: 'Test', + middleNames: [], + title: '', + email: 'direct@example.com', // Only first email is captured + phone: '+1234567890', // Only first phone is captured + }], + }, + { + input: `BEGIN:VCARD +VERSION:5.0 +UID:invalid-version +N:Invalid;Version; +FN:Version Invalid +END:VCARD`, + expected: [{ + uid: 'invalid-version', + firstName: 'Version', + lastName: 'Invalid', + middleNames: [], + title: '', + }], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:no-first-name +N:LastOnly;; +FN:LastOnly +END:VCARD`, + expected: [{ uid: 'no-first-name' }], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:empty-uid +N:Test;Empty; +FN:Empty Test +END:VCARD`, + expected: [{ + firstName: 'Empty', + lastName: 'Test', + middleNames: [], + title: '', + uid: 'empty-uid', + }], + }, + ]; + + for (const test of tests) { + const output = parseVCard(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that parseVCard handles edge cases', () => { + const edgeCases: { input: string; description: string; expected: any[] }[] = [ + { + input: '', + description: 'empty string', + expected: [], + }, + { + input: 'Not a vCard at all', + description: 'invalid format', + expected: [], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:incomplete`, + description: 'incomplete vCard without END', + expected: [], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:missing-required +FN:Missing Required Fields +END:VCARD`, + description: 'vCard without N field', + expected: [{ uid: 'missing-required' }], + }, + { + input: `BEGIN:VCARD +VERSION:4.0 +UID:empty-fields +N:;;; +FN:Empty Fields +EMAIL: +TEL: +NOTE: +END:VCARD`, + description: 'vCard with empty field values', + expected: [{ uid: 'empty-fields', notes: '' }], + }, + ]; + + for (const test of edgeCases) { + const output = parseVCard(test.input); + assertEquals(output, test.expected, `Failed for: ${test.description}`); + } +}); diff --git a/routes/api/contacts/add-addressbook.tsx b/routes/api/contacts/add-addressbook.tsx new file mode 100644 index 0000000..d292ed0 --- /dev/null +++ b/routes/api/contacts/add-addressbook.tsx @@ -0,0 +1,39 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AddressBook, ContactModel } from '/lib/models/contacts.ts'; + +interface Data {} + +export interface RequestBody { + name: string; +} + +export interface ResponseBody { + success: boolean; + addressBooks: AddressBook[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.name) { + return new Response('Bad request', { status: 400 }); + } + + const userId = context.state.user.id; + + await ContactModel.createAddressBook(userId, requestBody.name); + + const addressBooks = await ContactModel.listAddressBooks(userId); + + const responseBody: ResponseBody = { success: true, addressBooks }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/add.tsx b/routes/api/contacts/add.tsx new file mode 100644 index 0000000..5544574 --- /dev/null +++ b/routes/api/contacts/add.tsx @@ -0,0 +1,46 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Contact, ContactModel } from '/lib/models/contacts.ts'; +import { generateVCard } from '/lib/utils/contacts.ts'; + +interface Data {} + +export interface RequestBody { + firstName: string; + lastName?: string; + addressBookId: string; +} + +export interface ResponseBody { + success: boolean; + contacts: Contact[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.firstName || !requestBody.addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const userId = context.state.user.id; + + const contactId = crypto.randomUUID(); + + const vCard = generateVCard(contactId, requestBody.firstName, requestBody.lastName); + + await ContactModel.create(userId, requestBody.addressBookId, contactId, vCard); + + const contacts = await ContactModel.list(userId, requestBody.addressBookId); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/delete-addressbook.tsx b/routes/api/contacts/delete-addressbook.tsx new file mode 100644 index 0000000..053bb19 --- /dev/null +++ b/routes/api/contacts/delete-addressbook.tsx @@ -0,0 +1,39 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AddressBook, ContactModel } from '/lib/models/contacts.ts'; + +interface Data {} + +export interface RequestBody { + addressBookId: string; +} + +export interface ResponseBody { + success: boolean; + addressBooks: AddressBook[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const userId = context.state.user.id; + + await ContactModel.deleteAddressBook(userId, requestBody.addressBookId); + + const addressBooks = await ContactModel.listAddressBooks(userId); + + const responseBody: ResponseBody = { success: true, addressBooks }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/delete.tsx b/routes/api/contacts/delete.tsx new file mode 100644 index 0000000..438aed5 --- /dev/null +++ b/routes/api/contacts/delete.tsx @@ -0,0 +1,46 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Contact, ContactModel } from '/lib/models/contacts.ts'; + +interface Data {} + +export interface RequestBody { + contactId: string; + addressBookId: string; +} + +export interface ResponseBody { + success: boolean; + contacts: Contact[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.contactId || !requestBody.addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const userId = context.state.user.id; + + const contact = await ContactModel.get(userId, requestBody.addressBookId, requestBody.contactId); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + await ContactModel.delete(userId, contact.url); + + const contacts = await ContactModel.list(userId, requestBody.addressBookId); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/get-addressbooks.tsx b/routes/api/contacts/get-addressbooks.tsx new file mode 100644 index 0000000..07937da --- /dev/null +++ b/routes/api/contacts/get-addressbooks.tsx @@ -0,0 +1,27 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AddressBook, ContactModel } from '/lib/models/contacts.ts'; + +interface Data {} + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + addressBooks: AddressBook[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const addressBooks = await ContactModel.listAddressBooks(context.state.user.id); + + const responseBody: ResponseBody = { success: true, addressBooks }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/get.tsx b/routes/api/contacts/get.tsx new file mode 100644 index 0000000..d5d70ca --- /dev/null +++ b/routes/api/contacts/get.tsx @@ -0,0 +1,35 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Contact, ContactModel } from '/lib/models/contacts.ts'; + +interface Data {} + +export interface RequestBody { + addressBookId: string; +} + +export interface ResponseBody { + success: boolean; + contacts: Contact[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const contacts = await ContactModel.list(context.state.user.id, requestBody.addressBookId); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/import.tsx b/routes/api/contacts/import.tsx new file mode 100644 index 0000000..24f2d28 --- /dev/null +++ b/routes/api/contacts/import.tsx @@ -0,0 +1,51 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Contact, ContactModel } from '/lib/models/contacts.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; +import { getIdFromVCard, splitTextIntoVCards } from '/lib/utils/contacts.ts'; + +interface Data {} + +export interface RequestBody { + vCards: string; + addressBookId: string; +} + +export interface ResponseBody { + success: boolean; + contacts: Contact[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.vCards || !requestBody.addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const userId = context.state.user.id; + + const vCards = splitTextIntoVCards(requestBody.vCards); + + await concurrentPromises( + vCards.map((vCard) => async () => { + const contactId = getIdFromVCard(vCard); + + await ContactModel.create(userId, requestBody.addressBookId, contactId, vCard); + }), + 5, + ); + + const contacts = await ContactModel.list(userId, requestBody.addressBookId); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/contacts.tsx b/routes/contacts.tsx new file mode 100644 index 0000000..a469d5e --- /dev/null +++ b/routes/contacts.tsx @@ -0,0 +1,95 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AddressBook, Contact, ContactModel } from '/lib/models/contacts.ts'; +import Contacts from '/islands/contacts/Contacts.tsx'; +import { AppConfig } from '/lib/config.ts'; + +interface Data { + addressBookId: string; + userContacts: Contact[]; + userAddressBooks: AddressBook[]; + page: number; + contactsCount: number; + baseUrl: string; + search?: string; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const baseUrl = (await AppConfig.getConfig()).auth.baseUrl; + const contactsConfig = await AppConfig.getContactsConfig(); + + if (!contactsConfig.enableCardDavServer) { + throw new Error('CardDAV server is not enabled'); + } + + const userId = context.state.user.id; + + const searchParams = new URL(request.url).searchParams; + + const page = parseInt(searchParams.get('page') || '1', 10); + const search = searchParams.get('search') || undefined; + let addressBookId = searchParams.get('addressBookId') || undefined; + + let userAddressBooks = await ContactModel.listAddressBooks(userId); + + // Create default address book if none exists + if (userAddressBooks.length === 0) { + await ContactModel.createAddressBook(userId, 'Contacts'); + + userAddressBooks = await ContactModel.listAddressBooks(userId); + } + + if (!addressBookId) { + addressBookId = userAddressBooks[0].uid!; + } + + if (!addressBookId) { + throw new Error('Invalid address book ID'); + } + + const userContacts = await ContactModel.list(userId, addressBookId); + + const lowerCaseSearch = search?.toLowerCase(); + + const filteredContacts = lowerCaseSearch + ? userContacts.filter((contact) => + contact.firstName!.toLowerCase().includes(lowerCaseSearch) || + contact.lastName?.toLowerCase().includes(lowerCaseSearch) + ) + : userContacts; + + const contactsCount = filteredContacts.length; + + return await context.render({ + addressBookId, + userContacts: filteredContacts, + userAddressBooks, + page, + contactsCount, + baseUrl, + search, + }); + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/contacts/[contactId].tsx b/routes/contacts/[contactId].tsx new file mode 100644 index 0000000..b855412 --- /dev/null +++ b/routes/contacts/[contactId].tsx @@ -0,0 +1,127 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { convertFormDataToObject } from '/lib/utils/misc.ts'; +import { Contact, ContactModel } from '/lib/models/contacts.ts'; +import { getFormDataField } from '/lib/form-utils.tsx'; +import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx'; +import { updateVCard } from '/lib/utils/contacts.ts'; + +interface Data { + contact: Contact; + error?: string; + notice?: string; + formData: Record; + addressBookId: string; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { contactId } = context.params; + + const searchParams = new URL(request.url).searchParams; + const addressBookId = searchParams.get('addressBookId') || undefined; + + if (!addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const contact = await ContactModel.get(context.state.user.id, addressBookId, contactId); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + return await context.render({ contact, formData: {}, addressBookId }); + }, + async POST(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { contactId } = context.params; + + const searchParams = new URL(request.url).searchParams; + const addressBookId = searchParams.get('addressBookId') || undefined; + + if (!addressBookId) { + return new Response('Bad request', { status: 400 }); + } + + const contact = await ContactModel.get(context.state.user.id, addressBookId, contactId); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + const formData = await request.formData(); + + const updateType = getFormDataField(formData, 'update-type') as 'raw' | 'ui'; + + const firstName = getFormDataField(formData, 'first_name'); + const lastName = getFormDataField(formData, 'last_name'); + const email = getFormDataField(formData, 'main_email'); + const phone = getFormDataField(formData, 'main_phone'); + const notes = getFormDataField(formData, 'notes'); + const rawVCard = getFormDataField(formData, 'vcard'); + + try { + formFields(contact, updateType).forEach((field) => { + if (field.required) { + const value = formData.get(field.name); + + if (!value) { + throw new Error(`${field.label} is required`); + } + } + }); + + let updatedVCard = ''; + + if (updateType === 'raw') { + updatedVCard = rawVCard; + } else if (updateType === 'ui') { + if (!firstName) { + throw new Error(`First name is required.`); + } + + updatedVCard = updateVCard(contact.data || '', { firstName, lastName, email, phone, notes }); + } + + await ContactModel.update(context.state.user.id, contact.url, updatedVCard); + + return await context.render({ + contact, + notice: 'Contact updated successfully!', + formData: convertFormDataToObject(formData), + addressBookId, + }); + } catch (error) { + console.error(error); + return await context.render({ + contact, + error: (error as Error).toString(), + formData: convertFormDataToObject(formData), + addressBookId, + }); + } + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/files.tsx b/routes/files.tsx index 1b7986a..fb6bdfa 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -11,7 +11,6 @@ interface Data { currentPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCardDavEnabled: boolean; isCalDavEnabled: boolean; } @@ -42,10 +41,8 @@ export const handler: Handlers = { const userFiles = await FileModel.list(context.state.user.id, currentPath); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); - const contactsConfig = await AppConfig.getContactsConfig(); const calendarConfig = await AppConfig.getCalendarConfig(); - const isCardDavEnabled = contactsConfig.enableCardDavServer; const isCalDavEnabled = calendarConfig.enableCalDavServer; return await context.render({ @@ -54,7 +51,6 @@ export const handler: Handlers = { currentPath, baseUrl, isFileSharingAllowed: isPublicFileSharingAllowed, - isCardDavEnabled, isCalDavEnabled, }); }, @@ -69,7 +65,6 @@ export default function FilesPage({ data }: PageProps) initialPath={data.currentPath} baseUrl={data.baseUrl} isFileSharingAllowed={data.isFileSharingAllowed} - isCardDavEnabled={data.isCardDavEnabled} isCalDavEnabled={data.isCalDavEnabled} /> diff --git a/static/styles.css b/static/styles.css index 1beefb1..517f6c1 100644 --- a/static/styles.css +++ b/static/styles.css @@ -83,6 +83,10 @@ img.gray { filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%); } +img.blue { + filter: invert(74%) sepia(36%) saturate(7057%) hue-rotate(186deg) brightness(101%) contrast(97%); +} + details summary::-webkit-details-marker { display: none; }