Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

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

environment:
name: Production
Expand All @@ -24,8 +24,13 @@ jobs:
with:
node-version: ${{ matrix.node_version }}

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Install dependencies
run: pnpm --frozen-lockfile
run: pnpm install --frozen-lockfile

- name: Run `typecheck`
run: pnpm typecheck
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:

strategy:
matrix:
node-version: ['16']
node-version: ['20']

environment:
name: Production
Expand Down
5 changes: 3 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dav-migrate",
"version": "0.1.0",
"version": "0.2.0",
"description": "DSGVO-compliant CLI tool for migrating calendars between CalDAV providers (Google Calendar, Nextcloud, Baïkal)",
"keywords": [
"caldav",
Expand Down Expand Up @@ -42,10 +42,11 @@
"chalk": "^4.1.2",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"cross-fetch": "^4.1.0",
"dotenv": "^16.5.0",
"ical.js": "^2.1.0",
"inquirer": "^10.0.0",
"tsdav": "^2.1.5",
"tsdav": "^2.1.6",
"uuid": "^11.0.3"
},
"devDependencies": {
Expand Down
182 changes: 166 additions & 16 deletions cli/src/core/AddressBookCollectionMigrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,100 @@ export class AddressBookCollectionMigrator extends CollectionMigrator {
* Fetch all addressbooks from source
*/
async fetchSourceCollections(): Promise<DAVCollection[]> {
const addressBooks = await this.sourceClient.fetchAddressBooks();
return addressBooks as DAVCollection[];
// Temporarily override homeUrl to point to addressbook home for fetching
const originalHomeUrl = this.sourceClient.account?.homeUrl;

try {
// Determine the addressbook home URL (same logic as in createCollection)
const addressbookHomeSet = (this.sourceClient.account as any)?.addressbookHomeSet;

if (!addressbookHomeSet) {
// Need to construct the addressbook home URL
const serverUrl = this.sourceClient.account?.serverUrl?.replace(/\/+$/, '');
const username = this.sourceClient.account?.credentials?.username;

if (serverUrl && username) {
let addressbookHomeUrl: string;

if (this.config.source.provider === 'nextcloud') {
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}/`;
} else if (this.config.source.provider === 'generic') {
const principalUrl = this.sourceClient.account?.principalUrl;
if (principalUrl) {
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/');
if (!addressbookHomeUrl.endsWith('/')) addressbookHomeUrl += '/';
} else {
addressbookHomeUrl = originalHomeUrl || '';
}
} else {
addressbookHomeUrl = originalHomeUrl || '';
}

// Temporarily override homeUrl
if (this.sourceClient.account) {
(this.sourceClient.account as any).homeUrl = addressbookHomeUrl;
}
}
}

const addressBooks = await this.sourceClient.fetchAddressBooks();
return addressBooks as DAVCollection[];
} finally {
// Restore original homeUrl
if (this.sourceClient.account && originalHomeUrl) {
(this.sourceClient.account as any).homeUrl = originalHomeUrl;
}
}
}

/**
* Fetch all addressbooks from target
*/
async fetchTargetCollections(): Promise<DAVCollection[]> {
const addressBooks = await this.targetClient.fetchAddressBooks();
return addressBooks as DAVCollection[];
// Temporarily override homeUrl to point to addressbook home for fetching
const originalHomeUrl = this.targetClient.account?.homeUrl;

try {
// Determine the addressbook home URL (same logic as in createCollection)
const addressbookHomeSet = (this.targetClient.account as any)?.addressbookHomeSet;

if (!addressbookHomeSet) {
// Need to construct the addressbook home URL
const serverUrl = this.targetClient.account?.serverUrl?.replace(/\/+$/, '');
const username = this.targetClient.account?.credentials?.username;

if (serverUrl && username) {
let addressbookHomeUrl: string;

if (this.config.target.provider === 'nextcloud') {
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}/`;
} else if (this.config.target.provider === 'generic') {
const principalUrl = this.targetClient.account?.principalUrl;
if (principalUrl) {
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/');
if (!addressbookHomeUrl.endsWith('/')) addressbookHomeUrl += '/';
} else {
addressbookHomeUrl = originalHomeUrl || '';
}
} else {
addressbookHomeUrl = originalHomeUrl || '';
}

// Temporarily override homeUrl
if (this.targetClient.account) {
(this.targetClient.account as any).homeUrl = addressbookHomeUrl;
}
}
}

const addressBooks = await this.targetClient.fetchAddressBooks();
return addressBooks as DAVCollection[];
} finally {
// Restore original homeUrl
if (this.targetClient.account && originalHomeUrl) {
(this.targetClient.account as any).homeUrl = originalHomeUrl;
}
}
}

/**
Expand Down Expand Up @@ -76,24 +160,90 @@ export class AddressBookCollectionMigrator extends CollectionMigrator {
async createCollection(displayName: string): Promise<DAVCollection> {
const addressBookName = displayName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() ||
`addressbook-${Date.now()}`;
const homeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
const newAddressBookUrl = `${homeUrl}/${addressBookName}/`;

await this.targetClient.makeCollection({
url: newAddressBookUrl,
props: {
displayname: displayName,
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
},
});

// Determine the addressbook home URL
let addressbookHomeUrl: string;

// Try to get addressbookHomeSet first
const addressbookHomeSet = (this.targetClient.account as any)?.addressbookHomeSet;

if (addressbookHomeSet) {
// If addressbookHomeSet exists, use it
addressbookHomeUrl = (typeof addressbookHomeSet === 'string' ? addressbookHomeSet : addressbookHomeSet.href)
.replace(/\/+$/, '');
} else {
// Fallback: construct the URL based on provider and server URL
const serverUrl = this.targetClient.account?.serverUrl?.replace(/\/+$/, '');
const username = this.targetClient.account?.credentials?.username;

if (!serverUrl || !username) {
throw new Error('Could not determine server URL or username from account');
}

// Provider-specific URL construction
if (this.config.target.provider === 'nextcloud') {
// Nextcloud uses: /remote.php/dav/addressbooks/users/{username}/
addressbookHomeUrl = `${serverUrl}/remote.php/dav/addressbooks/users/${username}`;
} else if (this.config.target.provider === 'generic') {
// For generic CardDAV servers, try to construct from principalUrl
const principalUrl = this.targetClient.account?.principalUrl;
if (principalUrl) {
// Replace 'principals' with 'addressbooks' in the path
addressbookHomeUrl = principalUrl.replace(/principals\/[^/]+\//, 'addressbooks/').replace(/\/+$/, '');
} else {
// Last resort: use homeUrl but warn user
addressbookHomeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
console.warn(` Warning: Using homeUrl for addressbooks (may not work): ${addressbookHomeUrl}`);
}
} else {
// Default: try homeUrl
addressbookHomeUrl = this.targetClient.account?.homeUrl?.replace(/\/+$/, '') || '';
console.warn(` Warning: Unknown provider '${this.config.target.provider}', using homeUrl: ${addressbookHomeUrl}`);
}
}

if (!addressbookHomeUrl) {
throw new Error('Could not determine addressbook home URL from account');
}

const newAddressBookUrl = `${addressbookHomeUrl}/${addressBookName}/`;

// Use makeAddressBook if available (tsdav v2.1.6+), otherwise fallback to makeCollection
try {
if (typeof (this.targetClient as any).makeAddressBook === 'function') {
await (this.targetClient as any).makeAddressBook({
url: newAddressBookUrl,
props: {
[`${DAVNamespaceShort.DAV}:displayname`]: displayName,
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
[`${DAVNamespaceShort.DAV}:resourcetype`]: {
[`${DAVNamespaceShort.DAV}:collection`]: {},
[`${DAVNamespaceShort.CARDDAV}:addressbook`]: {},
},
},
});
} else {
// Fallback for older tsdav versions or providers that don't support RFC 5689
await this.targetClient.makeCollection({
url: newAddressBookUrl,
props: {
displayname: displayName,
[`${DAVNamespaceShort.CARDDAV}:addressbook-description`]: `Migrated from ${this.config.source.provider}`,
},
});
}
} catch (error) {
throw new Error(`Failed to create addressbook at ${newAddressBookUrl}: ${(error as Error).message}`);
}

// Fetch addressbooks again to get the newly created one
await this.targetRateLimiter.throttle();
const updatedAddressBooks = await this.targetClient.fetchAddressBooks();
const updatedAddressBooks = await this.fetchTargetCollections();

const newAddressBook = updatedAddressBooks.find((ab) => ab.url === newAddressBookUrl);

if (!newAddressBook) {
throw new Error(`Failed to create addressbook: ${displayName}`);
throw new Error(`Failed to create addressbook: ${displayName} at ${newAddressBookUrl}`);
}

return newAddressBook as DAVCollection;
Expand Down
Loading