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:
@@ -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>/`).
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
11
deno.json
11
deno.json
@@ -44,13 +44,14 @@
|
||||
"chart.js": "https://esm.sh/chart.js@4.4.9/auto",
|
||||
"otpauth": "https://esm.sh/otpauth@9.4.0",
|
||||
"qrcode": "https://esm.sh/qrcode@1.5.4",
|
||||
"openid-client": "https://esm.sh/openid-client@6.5.0",
|
||||
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1",
|
||||
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers",
|
||||
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0",
|
||||
"openid-client": "https://esm.sh/openid-client@6.6.3",
|
||||
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.2",
|
||||
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.2/helpers",
|
||||
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.2",
|
||||
"tailwindcss": "npm:tailwindcss@3.4.17",
|
||||
"tailwindcss/": "npm:/tailwindcss@3.4.17/",
|
||||
"tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js",
|
||||
"nodemailer": "npm:nodemailer@7.0.3"
|
||||
"nodemailer": "npm:nodemailer@7.0.5",
|
||||
"tsdav": "https://raw.githubusercontent.com/sunsama/tsdav/cc1c5a09b64c87bbee7e5f171cfcb6748e99469e/dist/tsdav.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
22
fresh.gen.ts
22
fresh.gen.ts
@@ -15,6 +15,13 @@ import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/m
|
||||
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
|
||||
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
|
||||
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
|
||||
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
|
||||
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
|
||||
import * as $api_contacts_get_addressbooks from './routes/api/contacts/get-addressbooks.tsx';
|
||||
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
|
||||
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
|
||||
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||
import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx';
|
||||
@@ -49,6 +56,8 @@ import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.
|
||||
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||
import * as $caldav from './routes/caldav.tsx';
|
||||
import * as $carddav from './routes/carddav.tsx';
|
||||
import * as $contacts from './routes/contacts.tsx';
|
||||
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||
import * as $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav from './routes/dav.tsx';
|
||||
import * as $expenses from './routes/expenses.tsx';
|
||||
@@ -73,6 +82,8 @@ import * as $signup from './routes/signup.tsx';
|
||||
import * as $Settings from './islands/Settings.tsx';
|
||||
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
|
||||
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx';
|
||||
@@ -99,6 +110,13 @@ const manifest = {
|
||||
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
|
||||
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
|
||||
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
|
||||
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
|
||||
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
|
||||
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
|
||||
'./routes/api/contacts/get-addressbooks.tsx': $api_contacts_get_addressbooks,
|
||||
'./routes/api/contacts/get.tsx': $api_contacts_get,
|
||||
'./routes/api/contacts/import.tsx': $api_contacts_import,
|
||||
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget,
|
||||
@@ -133,6 +151,8 @@ const manifest = {
|
||||
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||
'./routes/caldav.tsx': $caldav,
|
||||
'./routes/carddav.tsx': $carddav,
|
||||
'./routes/contacts.tsx': $contacts,
|
||||
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||
'./routes/dashboard.tsx': $dashboard,
|
||||
'./routes/dav.tsx': $dav,
|
||||
'./routes/expenses.tsx': $expenses,
|
||||
@@ -159,6 +179,8 @@ const manifest = {
|
||||
'./islands/Settings.tsx': $Settings,
|
||||
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
|
||||
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
|
||||
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper,
|
||||
|
||||
609
islands/contacts/Contacts.tsx
Normal file
609
islands/contacts/Contacts.tsx
Normal 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 ? <> </> : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
islands/contacts/ViewContact.tsx
Normal file
204
islands/contacts/ViewContact.tsx
Normal 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 ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
203
lib/models/contacts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
238
lib/utils/contacts.ts
Normal 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
503
lib/utils/contacts_test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
39
routes/api/contacts/add-addressbook.tsx
Normal file
39
routes/api/contacts/add-addressbook.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
46
routes/api/contacts/add.tsx
Normal file
46
routes/api/contacts/add.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
39
routes/api/contacts/delete-addressbook.tsx
Normal file
39
routes/api/contacts/delete-addressbook.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
46
routes/api/contacts/delete.tsx
Normal file
46
routes/api/contacts/delete.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
27
routes/api/contacts/get-addressbooks.tsx
Normal file
27
routes/api/contacts/get-addressbooks.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
35
routes/api/contacts/get.tsx
Normal file
35
routes/api/contacts/get.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
51
routes/api/contacts/import.tsx
Normal file
51
routes/api/contacts/import.tsx
Normal 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
95
routes/contacts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
routes/contacts/[contactId].tsx
Normal file
127
routes/contacts/[contactId].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user