From 289f34fe8ee3f2e890a913b8bdda1d3a79673e2e Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sun, 10 Aug 2025 07:48:16 +0100 Subject: [PATCH] Basic CardDav UI (Contacts) This implements a basic CardDav UI, titled "Contacts". It allows creating new contacts with a first name + last name, and editing their first and last names, main email, main phone, and notes. You can also import and export VCF (VCARD) files. It also allows editing the VCARD directly, for power users. Additionally, you can choose, create, or delete address books, and if there's no address book created yet in your CardDav server (first-time setup), it'll automatically create one, titled "Contacts". Finally, there are some dependency updates and a fix for the config not allowing disabling the `cardDav` or the `calDav` server. Related to #56 --- README.md | 2 +- bewcloud.config.sample.ts | 2 +- components/Header.tsx | 10 + components/files/MainFiles.tsx | 11 - deno.json | 11 +- docker-compose.yml | 2 +- fresh.gen.ts | 22 + islands/contacts/Contacts.tsx | 609 +++++++++++++++++++++ islands/contacts/ViewContact.tsx | 204 +++++++ islands/files/FilesWrapper.tsx | 3 - lib/config.ts | 10 +- lib/models/contacts.ts | 203 +++++++ lib/types.ts | 2 +- lib/utils/contacts.ts | 238 ++++++++ lib/utils/contacts_test.ts | 503 +++++++++++++++++ routes/api/contacts/add-addressbook.tsx | 39 ++ routes/api/contacts/add.tsx | 46 ++ routes/api/contacts/delete-addressbook.tsx | 39 ++ routes/api/contacts/delete.tsx | 46 ++ routes/api/contacts/get-addressbooks.tsx | 27 + routes/api/contacts/get.tsx | 35 ++ routes/api/contacts/import.tsx | 51 ++ routes/contacts.tsx | 95 ++++ routes/contacts/[contactId].tsx | 127 +++++ routes/files.tsx | 5 - static/styles.css | 4 + 26 files changed, 2317 insertions(+), 29 deletions(-) create mode 100644 islands/contacts/Contacts.tsx create mode 100644 islands/contacts/ViewContact.tsx create mode 100644 lib/models/contacts.ts create mode 100644 lib/utils/contacts.ts create mode 100644 lib/utils/contacts_test.ts create mode 100644 routes/api/contacts/add-addressbook.tsx create mode 100644 routes/api/contacts/add.tsx create mode 100644 routes/api/contacts/delete-addressbook.tsx create mode 100644 routes/api/contacts/delete.tsx create mode 100644 routes/api/contacts/get-addressbooks.tsx create mode 100644 routes/api/contacts/get.tsx create mode 100644 routes/api/contacts/import.tsx create mode 100644 routes/contacts.tsx create mode 100644 routes/contacts/[contactId].tsx 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; }