Skip to content

Commit 9937a73

Browse files
authored
Merge pull request #29 from PhilflowIO/feature/dav-migration-cli
feat: Add CardDAV/VTODO integration tests and fix Nextcloud addressbo…
2 parents b727deb + 16a5376 commit 9937a73

8 files changed

Lines changed: 1211 additions & 26 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111

1212
strategy:
1313
matrix:
14-
node-version: ['10', '12', '14', '16']
14+
node-version: ['18', '20', '22']
1515

1616
environment:
1717
name: Production
@@ -24,8 +24,13 @@ jobs:
2424
with:
2525
node-version: ${{ matrix.node_version }}
2626

27+
- name: Install pnpm
28+
uses: pnpm/action-setup@v4
29+
with:
30+
version: 9
31+
2732
- name: Install dependencies
28-
run: pnpm --frozen-lockfile
33+
run: pnpm install --frozen-lockfile
2934

3035
- name: Run `typecheck`
3136
run: pnpm typecheck

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010

1111
strategy:
1212
matrix:
13-
node-version: ['16']
13+
node-version: ['20']
1414

1515
environment:
1616
name: Production

cli/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dav-migrate",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "DSGVO-compliant CLI tool for migrating calendars between CalDAV providers (Google Calendar, Nextcloud, Baïkal)",
55
"keywords": [
66
"caldav",
@@ -42,10 +42,11 @@
4242
"chalk": "^4.1.2",
4343
"cli-progress": "^3.12.0",
4444
"commander": "^12.0.0",
45+
"cross-fetch": "^4.1.0",
4546
"dotenv": "^16.5.0",
4647
"ical.js": "^2.1.0",
4748
"inquirer": "^10.0.0",
48-
"tsdav": "^2.1.5",
49+
"tsdav": "^2.1.6",
4950
"uuid": "^11.0.3"
5051
},
5152
"devDependencies": {

cli/src/core/AddressBookCollectionMigrator.ts

Lines changed: 166 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,100 @@ export class AddressBookCollectionMigrator extends CollectionMigrator {
1616
* Fetch all addressbooks from source
1717
*/
1818
async fetchSourceCollections(): Promise<DAVCollection[]> {
19-
const addressBooks = await this.sourceClient.fetchAddressBooks();
20-
return addressBooks as DAVCollection[];
19+
// Temporarily override homeUrl to point to addressbook home for fetching
20+
const originalHomeUrl = this.sourceClient.account?.homeUrl;
21+
22+
try {
23+
// Determine the addressbook home URL (same logic as in createCollection)
24+
const addressbookHomeSet = (this.sourceClient.account as any)?.addressbookHomeSet;
25+
26+
if (!addressbookHomeSet) {
27+
// Need to construct the addressbook home URL
28+
const serverUrl = this.sourceClient.account?.serverUrl?.replace(/\/+$/, '');
29+
const username = this.sourceClient.account?.credentials?.username;
30+
31+
if (serverUrl && username) {
32+
let addressbookHomeUrl: string;
33+
34+
if (this.config.source.provider === 'nextcloud') {
35+
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}/`;
36+
} else if (this.config.source.provider === 'generic') {
37+
const principalUrl = this.sourceClient.account?.principalUrl;
38+
if (principalUrl) {
39+
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/');
40+
if (!addressbookHomeUrl.endsWith('/')) addressbookHomeUrl += '/';
41+
} else {
42+
addressbookHomeUrl = originalHomeUrl || '';
43+
}
44+
} else {
45+
addressbookHomeUrl = originalHomeUrl || '';
46+
}
47+
48+
// Temporarily override homeUrl
49+
if (this.sourceClient.account) {
50+
(this.sourceClient.account as any).homeUrl = addressbookHomeUrl;
51+
}
52+
}
53+
}
54+
55+
const addressBooks = await this.sourceClient.fetchAddressBooks();
56+
return addressBooks as DAVCollection[];
57+
} finally {
58+
// Restore original homeUrl
59+
if (this.sourceClient.account && originalHomeUrl) {
60+
(this.sourceClient.account as any).homeUrl = originalHomeUrl;
61+
}
62+
}
2163
}
2264

2365
/**
2466
* Fetch all addressbooks from target
2567
*/
2668
async fetchTargetCollections(): Promise<DAVCollection[]> {
27-
const addressBooks = await this.targetClient.fetchAddressBooks();
28-
return addressBooks as DAVCollection[];
69+
// Temporarily override homeUrl to point to addressbook home for fetching
70+
const originalHomeUrl = this.targetClient.account?.homeUrl;
71+
72+
try {
73+
// Determine the addressbook home URL (same logic as in createCollection)
74+
const addressbookHomeSet = (this.targetClient.account as any)?.addressbookHomeSet;
75+
76+
if (!addressbookHomeSet) {
77+
// Need to construct the addressbook home URL
78+
const serverUrl = this.targetClient.account?.serverUrl?.replace(/\/+$/, '');
79+
const username = this.targetClient.account?.credentials?.username;
80+
81+
if (serverUrl && username) {
82+
let addressbookHomeUrl: string;
83+
84+
if (this.config.target.provider === 'nextcloud') {
85+
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}/`;
86+
} else if (this.config.target.provider === 'generic') {
87+
const principalUrl = this.targetClient.account?.principalUrl;
88+
if (principalUrl) {
89+
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/');
90+
if (!addressbookHomeUrl.endsWith('/')) addressbookHomeUrl += '/';
91+
} else {
92+
addressbookHomeUrl = originalHomeUrl || '';
93+
}
94+
} else {
95+
addressbookHomeUrl = originalHomeUrl || '';
96+
}
97+
98+
// Temporarily override homeUrl
99+
if (this.targetClient.account) {
100+
(this.targetClient.account as any).homeUrl = addressbookHomeUrl;
101+
}
102+
}
103+
}
104+
105+
const addressBooks = await this.targetClient.fetchAddressBooks();
106+
return addressBooks as DAVCollection[];
107+
} finally {
108+
// Restore original homeUrl
109+
if (this.targetClient.account && originalHomeUrl) {
110+
(this.targetClient.account as any).homeUrl = originalHomeUrl;
111+
}
112+
}
29113
}
30114

31115
/**
@@ -76,24 +160,90 @@ export class AddressBookCollectionMigrator extends CollectionMigrator {
76160
async createCollection(displayName: string): Promise<DAVCollection> {
77161
const addressBookName = displayName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() ||
78162
`addressbook-${Date.now()}`;
79-
const homeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
80-
const newAddressBookUrl = `${homeUrl}/${addressBookName}/`;
81-
82-
await this.targetClient.makeCollection({
83-
url: newAddressBookUrl,
84-
props: {
85-
displayname: displayName,
86-
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
87-
},
88-
});
163+
164+
// Determine the addressbook home URL
165+
let addressbookHomeUrl: string;
166+
167+
// Try to get addressbookHomeSet first
168+
const addressbookHomeSet = (this.targetClient.account as any)?.addressbookHomeSet;
169+
170+
if (addressbookHomeSet) {
171+
// If addressbookHomeSet exists, use it
172+
addressbookHomeUrl = (typeof addressbookHomeSet === 'string' ? addressbookHomeSet : addressbookHomeSet.href)
173+
.replace(/\/+$/, '');
174+
} else {
175+
// Fallback: construct the URL based on provider and server URL
176+
const serverUrl = this.targetClient.account?.serverUrl?.replace(/\/+$/, '');
177+
const username = this.targetClient.account?.credentials?.username;
178+
179+
if (!serverUrl || !username) {
180+
throw new Error('Could not determine server URL or username from account');
181+
}
182+
183+
// Provider-specific URL construction
184+
if (this.config.target.provider === 'nextcloud') {
185+
// Nextcloud uses: /remote.php/dav/addressbooks/users/{username}/
186+
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}`;
187+
} else if (this.config.target.provider === 'generic') {
188+
// For generic CardDAV servers, try to construct from principalUrl
189+
const principalUrl = this.targetClient.account?.principalUrl;
190+
if (principalUrl) {
191+
// Replace 'principals' with 'addressbooks' in the path
192+
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/').replace(/\/+$/, '');
193+
} else {
194+
// Last resort: use homeUrl but warn user
195+
addressbookHomeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
196+
console.warn(` Warning: Using homeUrl for addressbooks (may not work): ${addressbookHomeUrl}`);
197+
}
198+
} else {
199+
// Default: try homeUrl
200+
addressbookHomeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
201+
console.warn(` Warning: Unknown provider '${this.config.target.provider}', using homeUrl: ${addressbookHomeUrl}`);
202+
}
203+
}
204+
205+
if (!addressbookHomeUrl) {
206+
throw new Error('Could not determine addressbook home URL from account');
207+
}
208+
209+
const newAddressBookUrl = `${addressbookHomeUrl}/${addressBookName}/`;
210+
211+
// Use makeAddressBook if available (tsdav v2.1.6+), otherwise fallback to makeCollection
212+
try {
213+
if (typeof (this.targetClient as any).makeAddressBook === 'function') {
214+
await (this.targetClient as any).makeAddressBook({
215+
url: newAddressBookUrl,
216+
props: {
217+
[`${DAVNamespaceShort.DAV}:displayname`]: displayName,
218+
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
219+
[`${DAVNamespaceShort.DAV}:resourcetype`]: {
220+
[`${DAVNamespaceShort.DAV}:collection`]: {},
221+
[`${DAVNamespaceShort.CARDDAV}:addressbook`]: {},
222+
},
223+
},
224+
});
225+
} else {
226+
// Fallback for older tsdav versions or providers that don't support RFC 5689
227+
await this.targetClient.makeCollection({
228+
url: newAddressBookUrl,
229+
props: {
230+
displayname: displayName,
231+
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
232+
},
233+
});
234+
}
235+
} catch (error) {
236+
throw new Error(`Failed to create addressbook at ${newAddressBookUrl}: ${(error as Error).message}`);
237+
}
89238

90239
// Fetch addressbooks again to get the newly created one
91240
await this.targetRateLimiter.throttle();
92-
const updatedAddressBooks = await this.targetClient.fetchAddressBooks();
241+
const updatedAddressBooks = await this.fetchTargetCollections();
242+
93243
const newAddressBook = updatedAddressBooks.find((ab) => ab.url === newAddressBookUrl);
94244

95245
if (!newAddressBook) {
96-
throw new Error(`Failed to create addressbook: ${displayName}`);
246+
throw new Error(`Failed to create addressbook: ${displayName} at ${newAddressBookUrl}`);
97247
}
98248

99249
return newAddressBook as DAVCollection;

0 commit comments

Comments
 (0)