Merge pull request #86 from bewcloud/feature/carddav-basic-ui
Basic CardDav UI (Contacts)
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