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
This commit is contained in:
Bruno Bernardino
2025-08-10 07:48:16 +01:00
parent 820d1622f6
commit 289f34fe8e
26 changed files with 2317 additions and 29 deletions

View File

@@ -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 /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).

View File

@@ -20,7 +20,7 @@ const config: PartialDeep<Config> = {
// 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',

View File

@@ -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 (
<>
<Head>

View File

@@ -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
? (
<section class='flex flex-row items-center justify-start my-12'>
<span class='font-semibold'>CardDav URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/carddav</code>
</section>
)
: null}
{!fileShareId && isCalDavEnabled
? (
<section class='flex flex-row items-center justify-start my-12'>

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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<boolean>(false);
const isDeleting = useSignal<boolean>(false);
const isExporting = useSignal<boolean>(false);
const isImporting = useSignal<boolean>(false);
const contacts = useSignal<Contact[]>(initialContacts);
const addressBooks = useSignal<AddressBook[]>(initialAddressBooks);
const selectedAddressBookId = useSignal<string>(initialAddressBookId);
const selectedAddressBookName = useSignal<string>(
initialAddressBooks.find((addressBook) => addressBook.uid === initialAddressBookId)?.displayName || 'Address Book',
);
const isAddressBooksDropdownOpen = useSignal<boolean>(false);
const isOptionsDropdownOpen = useSignal<boolean>(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 (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<section class='relative inline-block text-left mr-2'>
<form method='GET' action={`/contacts?addressBookId=${selectedAddressBookId.value}`} class='m-0 p-0'>
<input
class='input-field w-60'
type='search'
name='search'
value={search}
placeholder='Search contacts...'
/>
</form>
</section>
<section class='flex items-center'>
<section class='relative inline-block text-left ml-2'>
<div>
<button
type='button'
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600 truncate'
id='select-address-book-button'
aria-expanded='true'
aria-haspopup='true'
onClick={() => toggleAddressBooksDropdown()}
>
{selectedAddressBookName.value}
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
clip-rule='evenodd'
/>
</svg>
</button>
</div>
<div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
!isAddressBooksDropdownOpen.value ? 'hidden' : ''
}`}
role='menu'
aria-orientation='vertical'
aria-labelledby='select-address-book-button'
tabindex={-1}
>
{addressBooks.value.length > 1
? (
<div class='py-1'>
{addressBooks.value.filter((addressBook) => addressBook.uid !== selectedAddressBookId.value).map((
addressBook,
) => (
<button
type='button'
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 truncate`}
onClick={() => onClickSelectAddressBook(addressBook.uid!)}
>
{addressBook.displayName}
</button>
))}
</div>
)
: null}
<div class='py-1'>
<button
type='button'
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickAddAddressBook()}
>
New Address Book
</button>
<button
type='button'
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-red-600`}
onClick={() => onClickDeleteAddressBook(selectedAddressBookId.value)}
>
Delete "{selectedAddressBookName.value}"
</button>
</div>
</div>
</section>
<section class='relative inline-block text-left ml-2'>
<div>
<button
type='button'
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
id='import-export-button'
aria-expanded='true'
aria-haspopup='true'
onClick={() => toggleOptionsDropdown()}
>
VCF
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
clip-rule='evenodd'
/>
</svg>
</button>
</div>
<div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
!isOptionsDropdownOpen.value ? 'hidden' : ''
}`}
role='menu'
aria-orientation='vertical'
aria-labelledby='import-export-button'
tabindex={-1}
>
<div class='py-1'>
<button
type='button'
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickImportVCard()}
>
Import vCard
</button>
<button
type='button'
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickExportVCard()}
>
Export vCard
</button>
</div>
</div>
</section>
<button
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
type='button'
title='Add new contact'
onClick={() => onClickAddContact()}
>
<img
src='/images/add.svg'
alt='Add new contact'
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</section>
</section>
<section class='mx-auto max-w-7xl my-8'>
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
<thead>
<tr class='border-b border-slate-600'>
<th scope='col' class='px-6 py-4 font-medium text-white'>First Name</th>
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Name</th>
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
</tr>
</thead>
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
{contacts.value.map((contact) => (
<tr class='bg-slate-700 hover:bg-slate-600 group'>
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
<a href={`/contacts/${contact.uid}?addressBookId=${selectedAddressBookId.value}`}>
{contact.firstName}
</a>
</td>
<td class='px-6 py-4 text-slate-200'>
{contact.lastName}
</td>
<td class='px-6 py-4'>
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
onClick={() => onClickDeleteContact(contact.uid!)}
>
<img
src='/images/delete.svg'
class='red drop-shadow-md'
width={24}
height={24}
alt='Delete contact'
title='Delete contact'
/>
</span>
</td>
</tr>
))}
{contacts.value.length === 0
? (
<tr>
<td class='flex gap-3 px-6 py-4 font-normal' colspan={3}>
<div class='text-md'>
<div class='font-medium text-slate-400'>No contacts to show</div>
</div>
</td>
</tr>
)
: null}
</tbody>
</table>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{isDeleting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</>
)
: null}
{isExporting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</>
)
: null}
{isImporting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</>
)
: null}
{!isDeleting.value && !isExporting.value && !isImporting.value ? <>&nbsp;</> : null}
</span>
</section>
{pagesCount > 0
? (
<section class='flex justify-end'>
<nav class='isolate inline-flex -space-x-px rounded-md shadow-sm' aria-label='Pagination'>
<a
href={page > 1
? `/contacts?search=${search}&page=${page - 1}&addressBookId=${selectedAddressBookId.value}`
: 'javascript:void(0)'}
class='relative inline-flex items-center rounded-l-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
title='Previous'
>
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
clip-rule='evenodd'
/>
</svg>
</a>
{pages.map((pageNumber) => {
const isCurrent = pageNumber === page;
return (
<a
href={`/contacts?search=${search}&page=${pageNumber}&addressBookId=${selectedAddressBookId.value}`}
aria-current='page'
class={`relative inline-flex items-center ${
isCurrent ? 'bg-[#51A4FB] hover:bg-sky-400' : 'bg-slate-700 hover:bg-slate-600'
} px-4 py-2 text-sm font-semibold text-white`}
>
{pageNumber}
</a>
);
})}
<a
href={page < pagesCount
? `/contacts?search=${search}&page=${page + 1}&addressBookId=${selectedAddressBookId.value}`
: 'javascript:void(0)'}
class='relative inline-flex items-center rounded-r-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
title='Next'
>
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
clip-rule='evenodd'
/>
</svg>
</a>
</nav>
</section>
)
: null}
<section class='flex flex-row items-center justify-start my-12'>
<span class='font-semibold'>CardDAV URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/carddav</code>
</section>
</>
);
}

View File

@@ -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<string, any>;
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<boolean>(false);
const contact = useSignal<Contact>(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 (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<a href='/contacts' class='mr-2'>View contacts</a>
<section class='flex items-center'>
<button
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
type='button'
title='Delete contact'
onClick={() => onClickDeleteContact()}
>
<img
src='/images/delete.svg'
alt='Delete contact'
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</section>
</section>
<section class='mx-auto max-w-7xl my-8'>
{error
? (
<section class='notification-error'>
<h3>Failed to update!</h3>
<p>{error}</p>
</section>
)
: null}
{notice
? (
<section class='notification-success'>
<h3>Success!</h3>
<p>{notice}</p>
</section>
)
: null}
<form method='POST' class='mb-12'>
{formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update contact</button>
</section>
</form>
<hr class='my-8 border-slate-700' />
<details class='mb-12 group'>
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
Edit Raw vCard{' '}
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
</span>
</summary>
<form method='POST' class='mb-12'>
{formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update vCard</button>
</section>
</form>
</details>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{isDeleting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</>
)
: null}
{!isDeleting.value ? <>&nbsp;</> : null}
</span>
</section>
</>
);
}

View File

@@ -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}
/>

View File

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

203
lib/models/contacts.ts Normal file
View File

@@ -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<string, any> {
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<Contact[]> {
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<Contact | undefined> {
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<void> {
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<void> {
const client = await getClient(userId);
await client.updateVCard({
vCard: {
url: contactUrl,
data: vCard,
},
});
}
static async delete(
userId: string,
contactUrl: string,
): Promise<void> {
const client = await getClient(userId);
await client.deleteVCard({
vCard: {
url: contactUrl,
},
});
}
static async listAddressBooks(
userId: string,
): Promise<AddressBook[]> {
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<void> {
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 = `<?xml version="1.0" encoding="utf-8"?>
<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:set>
<d:prop>
<d:displayname>${encodeURIComponent(name)}</d:displayname>
<d:resourcetype>
<d:collection/>
<card:addressbook/>
</d:resourcetype>
</d:prop>
</d:set>
</d:mkcol>`;
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<void> {
const client = await getClient(userId);
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
await client.deleteObject({
url: addressBookUrl,
});
}
}

View File

@@ -147,7 +147,7 @@ export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses';
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts';
export interface Config {
auth: {

238
lib/utils/contacts.ts Normal file
View File

@@ -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<Contact>[] {
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
const partialContacts: Partial<Contact>[] = [];
let partialContact: Partial<Contact> = {};
let vCardVersion: VCardVersion = '2.1';
// Loop through every line
for (const line of lines) {
// Start new contact and vCard version
if (line.startsWith('BEGIN:VCARD')) {
partialContact = {};
vCardVersion = '2.1';
continue;
}
// Finish contact
if (line.startsWith('END:VCARD')) {
partialContacts.push(partialContact);
continue;
}
// Select proper vCard version
if (line.startsWith('VERSION:')) {
if (line.startsWith('VERSION:2.1')) {
vCardVersion = '2.1';
} else if (line.startsWith('VERSION:3.0')) {
vCardVersion = '3.0';
} else if (line.startsWith('VERSION:4.0')) {
vCardVersion = '4.0';
} else {
// Default to 2.1, log warning
vCardVersion = '2.1';
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
}
continue;
}
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
vCardVersion = '2.1';
}
if (line.startsWith('UID:')) {
const uid = line.replace('UID:', '');
if (!uid) {
continue;
}
partialContact.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;
}

503
lib/utils/contacts_test.ts Normal file
View File

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

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

View File

@@ -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<Data, FreshContextState> = {
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));
},
};

95
routes/contacts.tsx Normal file
View File

@@ -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<Data, FreshContextState> = {
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<Data, FreshContextState>) {
return (
<main>
<Contacts
initialAddressBookId={data?.addressBookId || ''}
initialContacts={data?.userContacts || []}
initialAddressBooks={data?.userAddressBooks || []}
baseUrl={data.baseUrl}
page={data?.page || 1}
contactsCount={data?.contactsCount || 0}
search={data?.search || ''}
/>
</main>
);
}

View File

@@ -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<string, any>;
addressBookId: string;
}
export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState>) {
return (
<main>
<ViewContact
initialContact={data.contact}
formData={data.formData}
error={data.error}
notice={data.notice}
addressBookId={data.addressBookId}
/>
</main>
);
}

View File

@@ -11,7 +11,6 @@ interface Data {
currentPath: string;
baseUrl: string;
isFileSharingAllowed: boolean;
isCardDavEnabled: boolean;
isCalDavEnabled: boolean;
}
@@ -42,10 +41,8 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
currentPath,
baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled,
});
},
@@ -69,7 +65,6 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath}
baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed}
isCardDavEnabled={data.isCardDavEnabled}
isCalDavEnabled={data.isCalDavEnabled}
/>
</main>

View File

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