Merge pull request #86 from bewcloud/feature/carddav-basic-ui

Basic CardDav UI (Contacts)
This commit is contained in:
Bruno Bernardino
2025-08-10 07:52:00 +01:00
committed by GitHub
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? ## 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>/`). 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) // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
// }, // },
// core: { // 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: { // visuals: {
// title: 'My own cloud', // title: 'My own cloud',

View File

@@ -56,6 +56,12 @@ export default function Header({ route, user, enabledApps }: Data) {
label: 'Expenses', label: 'Expenses',
} }
: null, : null,
enabledApps.includes('contacts')
? {
url: '/contacts',
label: 'Contacts',
}
: null,
]; ];
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[]; const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
@@ -77,6 +83,10 @@ export default function Header({ route, user, enabledApps }: Data) {
pageLabel = 'Budgets & Expenses'; pageLabel = 'Budgets & Expenses';
} }
if (route.startsWith('/contacts')) {
pageLabel = 'Contacts';
}
return ( return (
<> <>
<Head> <Head>

View File

@@ -48,7 +48,6 @@ interface MainFilesProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled?: boolean;
isCalDavEnabled?: boolean; isCalDavEnabled?: boolean;
fileShareId?: string; fileShareId?: string;
} }
@@ -60,7 +59,6 @@ export default function MainFiles(
initialPath, initialPath,
baseUrl, baseUrl,
isFileSharingAllowed, isFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled, isCalDavEnabled,
fileShareId, fileShareId,
}: MainFilesProps, }: MainFilesProps,
@@ -890,15 +888,6 @@ export default function MainFiles(
) )
: null} : 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 {!fileShareId && isCalDavEnabled
? ( ? (
<section class='flex flex-row items-center justify-start my-12'> <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", "chart.js": "https://esm.sh/chart.js@4.4.9/auto",
"otpauth": "https://esm.sh/otpauth@9.4.0", "otpauth": "https://esm.sh/otpauth@9.4.0",
"qrcode": "https://esm.sh/qrcode@1.5.4", "qrcode": "https://esm.sh/qrcode@1.5.4",
"openid-client": "https://esm.sh/openid-client@6.5.0", "openid-client": "https://esm.sh/openid-client@6.6.3",
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.2",
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.2/helpers",
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0", "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.2",
"tailwindcss": "npm:tailwindcss@3.4.17", "tailwindcss": "npm:tailwindcss@3.4.17",
"tailwindcss/": "npm:/tailwindcss@3.4.17/", "tailwindcss/": "npm:/tailwindcss@3.4.17/",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", "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: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.3.1 image: ghcr.io/bewcloud/bewcloud:v2.4.0
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 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_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_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_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_links from './routes/api/dashboard/save-links.tsx';
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.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'; 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 $api_notes_save from './routes/api/notes/save.tsx';
import * as $caldav from './routes/caldav.tsx'; import * as $caldav from './routes/caldav.tsx';
import * as $carddav from './routes/carddav.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 $dashboard from './routes/dashboard.tsx';
import * as $dav from './routes/dav.tsx'; import * as $dav from './routes/dav.tsx';
import * as $expenses from './routes/expenses.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 $Settings from './islands/Settings.tsx';
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx'; import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.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_Links from './islands/dashboard/Links.tsx';
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.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/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/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/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-links.tsx': $api_dashboard_save_links,
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget, './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/api/notes/save.tsx': $api_notes_save,
'./routes/caldav.tsx': $caldav, './routes/caldav.tsx': $caldav,
'./routes/carddav.tsx': $carddav, './routes/carddav.tsx': $carddav,
'./routes/contacts.tsx': $contacts,
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
'./routes/dashboard.tsx': $dashboard, './routes/dashboard.tsx': $dashboard,
'./routes/dav.tsx': $dav, './routes/dav.tsx': $dav,
'./routes/expenses.tsx': $expenses, './routes/expenses.tsx': $expenses,
@@ -159,6 +179,8 @@ const manifest = {
'./islands/Settings.tsx': $Settings, './islands/Settings.tsx': $Settings,
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings, './islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin, './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/Links.tsx': $dashboard_Links,
'./islands/dashboard/Notes.tsx': $dashboard_Notes, './islands/dashboard/Notes.tsx': $dashboard_Notes,
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper, './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; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled?: boolean;
isCalDavEnabled?: boolean; isCalDavEnabled?: boolean;
fileShareId?: string; fileShareId?: string;
} }
@@ -20,7 +19,6 @@ export default function FilesWrapper(
initialPath, initialPath,
baseUrl, baseUrl,
isFileSharingAllowed, isFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled, isCalDavEnabled,
fileShareId, fileShareId,
}: FilesWrapperProps, }: FilesWrapperProps,
@@ -32,7 +30,6 @@ export default function FilesWrapper(
initialPath={initialPath} initialPath={initialPath}
baseUrl={baseUrl} baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed} isFileSharingAllowed={isFileSharingAllowed}
isCardDavEnabled={isCardDavEnabled}
isCalDavEnabled={isCalDavEnabled} isCalDavEnabled={isCalDavEnabled}
fileShareId={fileShareId} fileShareId={fileShareId}
/> />

View File

@@ -24,7 +24,7 @@ export class AppConfig {
allowPublicSharing: false, allowPublicSharing: false,
}, },
core: { core: {
enabledApps: ['news', 'notes', 'photos', 'expenses'], enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'],
}, },
visuals: { visuals: {
title: '', title: '',
@@ -83,6 +83,14 @@ export class AppConfig {
...config.email, ...config.email,
...configFromFile.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'); 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 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 { export interface Config {
auth: { 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; currentPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
isCardDavEnabled: boolean;
isCalDavEnabled: boolean; isCalDavEnabled: boolean;
} }
@@ -42,10 +41,8 @@ export const handler: Handlers<Data, FreshContextState> = {
const userFiles = await FileModel.list(context.state.user.id, currentPath); const userFiles = await FileModel.list(context.state.user.id, currentPath);
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
const contactsConfig = await AppConfig.getContactsConfig();
const calendarConfig = await AppConfig.getCalendarConfig(); const calendarConfig = await AppConfig.getCalendarConfig();
const isCardDavEnabled = contactsConfig.enableCardDavServer;
const isCalDavEnabled = calendarConfig.enableCalDavServer; const isCalDavEnabled = calendarConfig.enableCalDavServer;
return await context.render({ return await context.render({
@@ -54,7 +51,6 @@ export const handler: Handlers<Data, FreshContextState> = {
currentPath, currentPath,
baseUrl, baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed, isFileSharingAllowed: isPublicFileSharingAllowed,
isCardDavEnabled,
isCalDavEnabled, isCalDavEnabled,
}); });
}, },
@@ -69,7 +65,6 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl} baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed} isFileSharingAllowed={data.isFileSharingAllowed}
isCardDavEnabled={data.isCardDavEnabled}
isCalDavEnabled={data.isCalDavEnabled} isCalDavEnabled={data.isCalDavEnabled}
/> />
</main> </main>

View File

@@ -83,6 +83,10 @@ img.gray {
filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%); 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 { details summary::-webkit-details-marker {
display: none; display: none;
} }