Skip to content

Commit 92baf9e

Browse files
committed
feat(AccountModal): optional calendar home url field
1 parent 055eb0e commit 92baf9e

9 files changed

Lines changed: 120 additions & 31 deletions

File tree

src-tauri/src/migrations/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod v012_tag_sort_config;
1313
mod v013_account_sort_order;
1414
mod v014_account_sort_config;
1515
mod v015_recurrence_fields;
16+
mod v016_account_calendar_home_url;
1617

1718
use tauri_plugin_sql::Migration;
1819

@@ -31,6 +32,7 @@ pub use v012_tag_sort_config::migration as migration_v012;
3132
pub use v013_account_sort_order::migration as migration_v013;
3233
pub use v014_account_sort_config::migration as migration_v014;
3334
pub use v015_recurrence_fields::migration as migration_v015;
35+
pub use v016_account_calendar_home_url::migration as migration_v016;
3436

3537
/// Returns all database migrations for the application
3638
pub fn get_migrations() -> Vec<Migration> {
@@ -50,5 +52,6 @@ pub fn get_migrations() -> Vec<Migration> {
5052
migration_v013(),
5153
migration_v014(),
5254
migration_v015(),
55+
migration_v016(),
5356
]
5457
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
use tauri_plugin_sql::{Migration, MigrationKind};
2+
3+
/// Adds optional calendar_home_url override to accounts
4+
pub fn migration() -> Migration {
5+
Migration {
6+
version: 16,
7+
description: "add_account_calendar_home_url",
8+
sql: r#"
9+
ALTER TABLE accounts ADD COLUMN calendar_home_url TEXT;
10+
"#,
11+
kind: MigrationKind::Up,
12+
}
13+
}

src/components/modals/AccountModal.tsx

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useQueryClient } from '@tanstack/react-query';
22
import CheckCircle from 'lucide-react/icons/check-circle';
3+
import ChevronDown from 'lucide-react/icons/chevron-down';
34
import Cloud from 'lucide-react/icons/cloud';
45
import Info from 'lucide-react/icons/info';
56
import Loader2 from 'lucide-react/icons/loader-2';
@@ -53,6 +54,8 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
5354
const [serverType, setServerType] = useState<ServerType>(
5455
() => preloadedConfig?.serverType || account?.serverType || 'generic',
5556
);
57+
const [calendarHomeUrl, setCalendarHomeUrl] = useState(() => account?.calendarHomeUrl || '');
58+
const [showAdvanced, setShowAdvanced] = useState(() => !!account?.calendarHomeUrl);
5659
const [isLoading, setIsLoading] = useState(false);
5760
const [isTesting, setIsTesting] = useState(false);
5861
const [testSuccess, setTestSuccess] = useState(false);
@@ -68,13 +71,19 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
6871
useModalEscapeKey(onClose);
6972

7073
// Reset test state when credentials change
71-
const [prevCredentials, setPrevCredentials] = useState({ serverUrl, username, password });
74+
const [prevCredentials, setPrevCredentials] = useState({
75+
serverUrl,
76+
username,
77+
password,
78+
calendarHomeUrl,
79+
});
7280
if (
7381
serverUrl !== prevCredentials.serverUrl ||
7482
username !== prevCredentials.username ||
75-
password !== prevCredentials.password
83+
password !== prevCredentials.password ||
84+
calendarHomeUrl !== prevCredentials.calendarHomeUrl
7685
) {
77-
setPrevCredentials({ serverUrl, username, password });
86+
setPrevCredentials({ serverUrl, username, password, calendarHomeUrl });
7887
if (testSuccess || testedConnectionId) {
7988
setTestSuccess(false);
8089
setTestedConnectionId(null);
@@ -178,6 +187,7 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
178187
username,
179188
effectivePassword,
180189
serverType,
190+
calendarHomeUrl.trim() || undefined,
181191
);
182192

183193
// Check if this is a Vikunja server and warn the user
@@ -231,6 +241,7 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
231241
username,
232242
effectivePassword,
233243
serverType,
244+
calendarHomeUrl.trim() || undefined,
234245
);
235246
}
236247

@@ -242,6 +253,7 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
242253
username,
243254
password: effectivePassword || account.password,
244255
serverType,
256+
calendarHomeUrl: calendarHomeUrl.trim() || undefined,
245257
},
246258
});
247259
} else {
@@ -269,6 +281,7 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
269281
username,
270282
effectivePassword,
271283
serverType,
284+
calendarHomeUrl.trim() || undefined,
272285
);
273286

274287
// Check if this is a Vikunja server and warn the user
@@ -297,6 +310,7 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
297310
username,
298311
password: effectivePassword,
299312
serverType,
313+
calendarHomeUrl: calendarHomeUrl.trim() || undefined,
300314
},
301315
{
302316
onSuccess: async (newAccount) => {
@@ -377,7 +391,11 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
377391
</button>
378392
</div>
379393

380-
<form onSubmit={handleSubmit} className="p-4 space-y-4">
394+
<form
395+
onSubmit={handleSubmit}
396+
className="flex flex-col min-h-0 max-h-[calc(90vh-4rem)]"
397+
>
398+
<div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
381399
<div>
382400
<label
383401
htmlFor="account-name"
@@ -494,6 +512,43 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
494512
/>
495513
</div>
496514

515+
<div>
516+
<button
517+
type="button"
518+
onClick={() => setShowAdvanced((v) => !v)}
519+
className="flex items-center gap-1 text-xs text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
520+
>
521+
<ChevronDown
522+
className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? '' : '-rotate-90'}`}
523+
/>
524+
Advanced
525+
</button>
526+
{showAdvanced && (
527+
<div className="mt-3 space-y-3">
528+
<div>
529+
<label
530+
htmlFor="calendar-home-url"
531+
className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"
532+
>
533+
Calendar Home URL
534+
</label>
535+
<ComposedInput
536+
id="calendar-home-url"
537+
type="url"
538+
value={calendarHomeUrl}
539+
onChange={setCalendarHomeUrl}
540+
placeholder="https://caldav.example.com/calendars/user/"
541+
className="w-full px-3 py-2 text-sm text-surface-800 dark:text-surface-200 bg-surface-100 dark:bg-surface-700 border border-transparent rounded-lg focus:outline-none focus:border-primary-300 dark:focus:border-primary-400 focus:bg-white dark:focus:bg-primary-900/30 transition-colors"
542+
/>
543+
<p className="mt-1.5 text-xs flex flex-row text-surface-500 dark:text-surface-400">
544+
<Info className="inline w-3.5 h-3.5 mr-1 shrink-0 text-surface-400" />
545+
Use this if auto-discovery is not possible.
546+
</p>
547+
</div>
548+
</div>
549+
)}
550+
</div>
551+
497552
{error && (
498553
<div className="p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
499554
{error}
@@ -560,7 +615,9 @@ export const AccountModal = ({ account, onClose, preloadedConfig }: AccountModal
560615
</div>
561616
)}
562617

563-
<div className="flex justify-between gap-3 pt-2">
618+
</div>
619+
620+
<div className="flex justify-between gap-3 p-4 pt-3 border-t border-surface-200 dark:border-surface-700 shrink-0">
564621
<button
565622
type="button"
566623
onClick={handleTestConnection}

src/lib/caldav/connection.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ const discoverGenericUrls = async (
141141

142142
// Try to extract principal from the response we already have before issuing
143143
// another PROPFIND
144-
let discoveredPrincipal: string | null =
145-
davRootBody ? (davRootBody.match(PRINCIPAL_RE)?.[1] ?? null) : null;
144+
let discoveredPrincipal: string | null = davRootBody
145+
? (davRootBody.match(PRINCIPAL_RE)?.[1] ?? null)
146+
: null;
146147

147148
if (!discoveredPrincipal) {
148149
discoveredPrincipal = await discoverPrincipal(davRootUrl, credentials);
@@ -179,6 +180,7 @@ export const connect = async (
179180
username: string,
180181
password: string,
181182
serverType: ServerType = 'generic',
183+
calendarHomeUrl?: string,
182184
): Promise<{ principalUrl: string; displayName: string; calendarHome: string }> => {
183185
const credentials: CalDAVCredentials = { username, password };
184186

@@ -196,26 +198,32 @@ export const connect = async (
196198
let principalUrl: string;
197199
let calendarHome: string;
198200

199-
switch (serverType) {
200-
case 'rustical':
201-
case 'radicale':
202-
case 'baikal':
203-
case 'nextcloud': {
204-
const config = SERVER_CONFIGS[serverType];
205-
principalUrl = `${baseUrl}${config.principalPath(username)}`;
206-
calendarHome = config.calendarHomePath
207-
? `${baseUrl}${config.calendarHomePath(username)}`
208-
: principalUrl;
209-
break;
210-
}
211-
case 'fastmail':
212-
case 'mailbox':
213-
case 'generic': {
214-
({ principalUrl, calendarHome } = await discoverGenericUrls(baseUrl, credentials));
215-
break;
201+
// If a direct calendar home URL is provided, skip autodiscovery entirely
202+
if (calendarHomeUrl) {
203+
calendarHome = `${calendarHomeUrl.replace(/\/$/, '')}/`;
204+
principalUrl = calendarHome;
205+
} else {
206+
switch (serverType) {
207+
case 'rustical':
208+
case 'radicale':
209+
case 'baikal':
210+
case 'nextcloud': {
211+
const config = SERVER_CONFIGS[serverType];
212+
principalUrl = `${baseUrl}${config.principalPath(username)}`;
213+
calendarHome = config.calendarHomePath
214+
? `${baseUrl}${config.calendarHomePath(username)}`
215+
: principalUrl;
216+
break;
217+
}
218+
case 'fastmail':
219+
case 'mailbox':
220+
case 'generic': {
221+
({ principalUrl, calendarHome } = await discoverGenericUrls(baseUrl, credentials));
222+
break;
223+
}
224+
default:
225+
throw new Error(`Unknown server type: ${serverType}`);
216226
}
217-
default:
218-
throw new Error(`Unknown server type: ${serverType}`);
219227
}
220228

221229
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>
@@ -263,6 +271,7 @@ export const reconnect = async (account: Account): Promise<void> => {
263271
account.username,
264272
account.password,
265273
account.serverType ?? 'generic',
274+
account.calendarHomeUrl,
266275
);
267276
};
268277

src/lib/caldav/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ export class CalDAVClient {
2525
username: string,
2626
password: string,
2727
serverType: ServerType = 'generic',
28+
calendarHomeUrl?: string,
2829
): Promise<{ principalUrl: string; displayName: string; calendarHome: string }> {
29-
return connectionOps.connect(accountId, serverUrl, username, password, serverType);
30+
return connectionOps.connect(accountId, serverUrl, username, password, serverType, calendarHomeUrl);
3031
}
3132

3233
static disconnect(accountId: string): void {

src/lib/database/accounts.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,23 @@ export const createAccount = async (conn: DatabasePlugin, accountData: Partial<A
3434
username: accountData.username ?? '',
3535
password: accountData.password ?? '',
3636
serverType: accountData.serverType,
37+
calendarHomeUrl: accountData.calendarHomeUrl,
3738
calendars: [],
3839
isActive: true,
3940
sortOrder: accountData.sortOrder || maxOrder + 100,
4041
};
4142

4243
await conn.execute(
43-
`INSERT INTO accounts (id, name, server_url, username, password, server_type, last_sync, is_active, sort_order)
44-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
44+
`INSERT INTO accounts (id, name, server_url, username, password, server_type, calendar_home_url, last_sync, is_active, sort_order)
45+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
4546
[
4647
account.id,
4748
account.name,
4849
account.serverUrl,
4950
account.username,
5051
account.password,
5152
account.serverType || null,
53+
account.calendarHomeUrl || null,
5254
account.lastSync ? account.lastSync.toISOString() : null,
5355
account.isActive ? 1 : 0,
5456
account.sortOrder,
@@ -74,14 +76,15 @@ export const updateAccount = async (
7476
const updated: Account = { ...existing, ...updates };
7577

7678
await conn.execute(
77-
`UPDATE accounts SET name = $1, server_url = $2, username = $3, password = $4, server_type = $5, last_sync = $6, is_active = $7, sort_order = $8
78-
WHERE id = $9`,
79+
`UPDATE accounts SET name = $1, server_url = $2, username = $3, password = $4, server_type = $5, calendar_home_url = $6, last_sync = $7, is_active = $8, sort_order = $9
80+
WHERE id = $10`,
7981
[
8082
updated.name,
8183
updated.serverUrl,
8284
updated.username,
8385
updated.password,
8486
updated.serverType || null,
87+
updated.calendarHomeUrl || null,
8588
updated.lastSync ? updated.lastSync.toISOString() : null,
8689
updated.isActive ? 1 : 0,
8790
updated.sortOrder,

src/lib/database/converters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const rowToAccount = (row: AccountRow, calendars: Calendar[]): Account =>
6565
username: row.username,
6666
password: row.password,
6767
serverType: (row.server_type as ServerType) || undefined,
68+
calendarHomeUrl: row.calendar_home_url || undefined,
6869
calendars: calendars.filter((c) => c.accountId === row.id),
6970
lastSync: row.last_sync ? new Date(row.last_sync) : undefined,
7071
isActive: row.is_active === 1,

src/types/database.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface AccountRow {
3838
username: string;
3939
password: string;
4040
server_type: string | null;
41+
calendar_home_url: string | null;
4142
last_sync: string | null;
4243
is_active: number;
4344
sort_order: number | null;

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export interface Account {
153153
username: string;
154154
password: string;
155155
serverType?: ServerType;
156+
calendarHomeUrl?: string;
156157
calendars: Calendar[];
157158
lastSync?: Date;
158159
isActive: boolean;

0 commit comments

Comments
 (0)