const axios = require('axios');
const { parseStringPromise } = require('xml2js');
const { AppError } = require('../../../shared/errors/AppError');
const logger = require('../../../services/logService');
const RemoteCalendarRepository = require('../repositories/remote-calendar-repository');
const { parseVTODOToTask } = require('../icalendar/vtodo-parser');
const encryptionService = require('../services/encryption-service');
class PullPhase {
async execute(calendar, userId, options = {}) {
const { dryRun = false } = options;
logger.logInfo(
`Pull phase starting for calendar ${calendar.id} (user: ${userId})`
);
const remoteCalendar =
await RemoteCalendarRepository.findByLocalCalendarId(calendar.id);
if (!remoteCalendar) {
logger.logInfo(
`No remote calendar configured for calendar ${calendar.id}, skipping pull`
);
return {
success: true,
skipped: true,
reason: 'No remote calendar configured',
changedTasks: [],
};
}
if (!remoteCalendar.enabled) {
logger.logInfo(
`Remote calendar ${remoteCalendar.id} is disabled, skipping pull`
);
return {
success: true,
skipped: true,
reason: 'Remote calendar disabled',
changedTasks: [],
};
}
try {
const changedTasks = await this._fetchChangesFromRemote(
remoteCalendar,
calendar
);
logger.logInfo(
`Pull phase completed: fetched ${changedTasks.length} changed tasks`
);
return {
success: true,
changedTasks,
fetchedCount: changedTasks.length,
};
} catch (error) {
logger.logError(
`Pull phase failed for calendar ${calendar.id}: ${error.message}`,
error
);
throw new AppError(
`Failed to pull from remote: ${error.message}`,
500
);
}
}
async _fetchChangesFromRemote(remoteCalendar, calendar) {
const password = encryptionService.decrypt(
remoteCalendar.password_encrypted
);
const baseUrl = remoteCalendar.server_url.replace(/\/$/, '');
const calendarPath = remoteCalendar.calendar_path.replace(/^\//, '');
const calendarUrl = `${baseUrl}/${calendarPath}`;
logger.logInfo(
`Fetching changes from remote CalDAV: ${remoteCalendar.server_url}`
);
const syncToken = remoteCalendar.server_sync_token;
let reportBody;
if (syncToken) {
reportBody = this._buildSyncCollectionReport(syncToken);
} else {
reportBody = this._buildInitialSyncReport();
}
try {
const response = await axios({
method: 'REPORT',
url: calendarUrl,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
Depth: '1',
},
auth: {
username: remoteCalendar.username,
password: password,
},
data: reportBody,
timeout: parseInt(
process.env.CALDAV_REQUEST_TIMEOUT || '30000',
10
),
});
return await this._parseReportResponse(
response.data,
remoteCalendar,
calendar
);
} catch (error) {
if (error.response?.status === 401) {
throw new AppError(
'Authentication failed with remote CalDAV server',
401
);
}
logger.logError(
`Failed to fetch from remote CalDAV: ${error.message}`,
error
);
throw error;
}
}
_buildSyncCollectionReport(syncToken) {
return `
${syncToken}
1
`;
}
_buildInitialSyncReport() {
return `
`;
}
async _parseReportResponse(xmlData, remoteCalendar, calendar) {
const parsed = await parseStringPromise(xmlData, {
explicitArray: false,
tagNameProcessors: [this._stripNamespace],
});
const changedTasks = [];
const responses =
parsed?.multistatus?.response ||
parsed?.['sync-collection']?.response ||
[];
const responseArray = Array.isArray(responses)
? responses
: [responses];
for (const response of responseArray) {
try {
const href = response.href;
if (!href || href.endsWith('/')) {
continue;
}
const etag = response.propstat?.prop?.getetag?.replace(
/^"|"$/g,
''
);
const calendarData =
response.propstat?.prop?.['calendar-data'] ||
response.propstat?.prop?.calendardata;
const status = response.status || response.propstat?.status;
if (status && status.includes('404')) {
changedTasks.push({
action: 'delete',
href,
etag,
});
continue;
}
if (!calendarData) {
const taskUrl = `${remoteCalendar.server_url}${href}`;
const taskData = await this._fetchTaskData(
taskUrl,
remoteCalendar
);
if (taskData) {
changedTasks.push(taskData);
}
continue;
}
const taskData = await parseVTODOToTask(calendarData);
if (taskData) {
changedTasks.push({
action: 'create_or_update',
href,
etag,
task: taskData,
});
}
} catch (error) {
logger.logError(
`Failed to parse task from remote: ${error.message}`,
error
);
}
}
const newSyncToken =
parsed?.multistatus?.['sync-token'] ||
parsed?.['sync-collection']?.['sync-token'];
if (newSyncToken) {
await RemoteCalendarRepository.updateServerSyncToken(
remoteCalendar.id,
newSyncToken
);
}
return changedTasks;
}
async _fetchTaskData(taskUrl, remoteCalendar) {
try {
const password = encryptionService.decrypt(
remoteCalendar.password_encrypted
);
const response = await axios({
method: 'GET',
url: taskUrl,
auth: {
username: remoteCalendar.username,
password: password,
},
timeout: parseInt(
process.env.CALDAV_REQUEST_TIMEOUT || '30000',
10
),
});
const etag = response.headers.etag?.replace(/^"|"$/g, '');
const taskData = await parseVTODOToTask(response.data);
return {
action: 'create_or_update',
href: new URL(taskUrl).pathname,
etag,
task: taskData,
};
} catch (error) {
logger.logError(
`Failed to fetch task data from ${taskUrl}: ${error.message}`,
error
);
return null;
}
}
_stripNamespace(name) {
return name.replace(/^.*:/, '');
}
}
module.exports = PullPhase;