Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
346 lines
12 KiB
JavaScript
346 lines
12 KiB
JavaScript
/**
|
|
* Restore deleted Google Calendar events from trash
|
|
*
|
|
* Google Calendar keeps deleted events for 30 days with status "cancelled".
|
|
* This script finds them and restores by patching status back to "confirmed".
|
|
*
|
|
* Usage: node --experimental-modules scripts/restore-google-calendar-events.js [--dry-run]
|
|
*/
|
|
|
|
import { google } from 'googleapis';
|
|
import axios from 'axios';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import CryptoJS from 'crypto-js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Config paths (same as GoogleCalendarService)
|
|
const CALENDAR_CONFIG_PATH = path.resolve(__dirname, '../google-calendar-config.json');
|
|
const FALLBACK_CONFIG_PATH = path.resolve(__dirname, '../google-oauth-config.json');
|
|
const CONFIG_PATH = fs.existsSync(CALENDAR_CONFIG_PATH) ? CALENDAR_CONFIG_PATH : FALLBACK_CONFIG_PATH;
|
|
const TOKENS_PATH = path.resolve(__dirname, '../google-calendar-tokens.json');
|
|
|
|
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || process.env.JWT_SECRET || 'dev_jwt_secret_change_in_production';
|
|
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function decrypt(ciphertext) {
|
|
const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY);
|
|
return bytes.toString(CryptoJS.enc.Utf8);
|
|
}
|
|
|
|
function loadConfig() {
|
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
const config = JSON.parse(raw);
|
|
let clientSecret = config.clientSecret;
|
|
if (clientSecret && clientSecret.startsWith('U2F')) {
|
|
clientSecret = decrypt(clientSecret);
|
|
}
|
|
return { clientId: config.clientId, clientSecret, redirectUri: config.redirectUri };
|
|
}
|
|
|
|
function loadTokens() {
|
|
const raw = fs.readFileSync(TOKENS_PATH, 'utf-8');
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
function saveTokens(tokens) {
|
|
fs.writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2), 'utf-8');
|
|
}
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function getAuthClient(userId) {
|
|
const config = loadConfig();
|
|
const tokens = loadTokens();
|
|
const userTokens = tokens[`user_${userId}`];
|
|
|
|
if (!userTokens) throw new Error(`No tokens for user ${userId}`);
|
|
|
|
const oauth2Client = new google.auth.OAuth2(config.clientId, config.clientSecret);
|
|
|
|
let accessToken = userTokens.access_token;
|
|
const refreshToken = userTokens.refresh_token ? decrypt(userTokens.refresh_token) : null;
|
|
|
|
// Always refresh to get a fresh token
|
|
if (refreshToken) {
|
|
console.log('🔄 Refreshing access token...');
|
|
const response = await axios.post('https://oauth2.googleapis.com/token', {
|
|
client_id: config.clientId,
|
|
client_secret: config.clientSecret,
|
|
refresh_token: refreshToken,
|
|
grant_type: 'refresh_token',
|
|
});
|
|
accessToken = response.data.access_token;
|
|
const newExpiry = new Date(Date.now() + (response.data.expires_in || 3600) * 1000).toISOString();
|
|
|
|
tokens[`user_${userId}`] = {
|
|
...userTokens,
|
|
access_token: accessToken,
|
|
token_expiry: newExpiry,
|
|
};
|
|
saveTokens(tokens);
|
|
console.log('✅ Token refreshed, expires:', newExpiry);
|
|
}
|
|
|
|
oauth2Client.setCredentials({ access_token: accessToken, refresh_token: refreshToken });
|
|
return oauth2Client;
|
|
}
|
|
|
|
// ── Main Logic ───────────────────────────────────────────────────────────────
|
|
|
|
async function findDeletedEvents(calendar, calendarId) {
|
|
const deletedEvents = [];
|
|
let pageToken = null;
|
|
|
|
// Look back 60 days to catch all recent deletions
|
|
const timeMin = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
do {
|
|
const params = {
|
|
calendarId,
|
|
showDeleted: true,
|
|
timeMin,
|
|
maxResults: 250,
|
|
singleEvents: false, // Get recurring event masters too
|
|
};
|
|
if (pageToken) params.pageToken = pageToken;
|
|
|
|
const res = await calendar.events.list(params);
|
|
const events = res.data.items || [];
|
|
|
|
for (const event of events) {
|
|
if (event.status === 'cancelled') {
|
|
deletedEvents.push(event);
|
|
}
|
|
}
|
|
|
|
pageToken = res.data.nextPageToken;
|
|
} while (pageToken);
|
|
|
|
return deletedEvents;
|
|
}
|
|
|
|
async function restoreEvent(calendar, calendarId, event) {
|
|
try {
|
|
// For recurring event instances (have recurringEventId), we need special handling
|
|
if (event.recurringEventId) {
|
|
// This is a cancelled instance of a recurring event
|
|
// We need to check if the parent recurring event still exists
|
|
try {
|
|
const parent = await calendar.events.get({
|
|
calendarId,
|
|
eventId: event.recurringEventId,
|
|
});
|
|
|
|
if (parent.data.status === 'cancelled') {
|
|
// Parent is also deleted - restore parent first
|
|
console.log(` ↳ Parent recurring event ${event.recurringEventId} also deleted, restoring parent...`);
|
|
if (!DRY_RUN) {
|
|
await calendar.events.patch({
|
|
calendarId,
|
|
eventId: event.recurringEventId,
|
|
requestBody: { status: 'confirmed' },
|
|
});
|
|
}
|
|
return { success: true, note: 'Restored via parent recurring event' };
|
|
} else {
|
|
// Parent exists and is confirmed - restore this instance
|
|
if (!DRY_RUN) {
|
|
await calendar.events.patch({
|
|
calendarId,
|
|
eventId: event.id,
|
|
requestBody: { status: 'confirmed' },
|
|
});
|
|
}
|
|
return { success: true, note: 'Restored recurring instance' };
|
|
}
|
|
} catch (parentErr) {
|
|
// Parent not found or error
|
|
console.log(` ↳ Could not access parent ${event.recurringEventId}: ${parentErr.message}`);
|
|
// Try direct restore anyway
|
|
if (!DRY_RUN) {
|
|
try {
|
|
await calendar.events.patch({
|
|
calendarId,
|
|
eventId: event.id,
|
|
requestBody: { status: 'confirmed' },
|
|
});
|
|
return { success: true, note: 'Direct restore of recurring instance' };
|
|
} catch (directErr) {
|
|
return { success: false, error: directErr.message };
|
|
}
|
|
}
|
|
return { success: true, note: 'Would attempt direct restore (dry-run)' };
|
|
}
|
|
}
|
|
|
|
// Regular (non-recurring) event - simple restore
|
|
if (!DRY_RUN) {
|
|
await calendar.events.patch({
|
|
calendarId,
|
|
eventId: event.id,
|
|
requestBody: { status: 'confirmed' },
|
|
});
|
|
}
|
|
return { success: true, note: 'Restored' };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('='.repeat(70));
|
|
console.log(' Google Calendar Event Restoration Tool');
|
|
console.log(DRY_RUN ? ' MODE: DRY RUN (no changes will be made)' : ' MODE: LIVE (will restore events!)');
|
|
console.log('='.repeat(70));
|
|
console.log();
|
|
|
|
const userId = 1;
|
|
const auth = await getAuthClient(userId);
|
|
const calendar = google.calendar({ version: 'v3', auth });
|
|
|
|
// Get list of calendars
|
|
const calListRes = await calendar.calendarList.list();
|
|
const calendars = calListRes.data.items || [];
|
|
|
|
console.log(`📅 Found ${calendars.length} calendars:\n`);
|
|
calendars.forEach(c => console.log(` - ${c.summary} (${c.id})`));
|
|
console.log();
|
|
|
|
// Target calendars (the ones that had deletions)
|
|
const targetCalendars = [
|
|
'bogolepovavikos@gmail.com',
|
|
'geramonnn@gmail.com',
|
|
];
|
|
|
|
let totalFound = 0;
|
|
let totalRestored = 0;
|
|
let totalFailed = 0;
|
|
const results = [];
|
|
|
|
for (const calId of targetCalendars) {
|
|
const calInfo = calendars.find(c => c.id === calId);
|
|
const calName = calInfo ? calInfo.summary : calId;
|
|
|
|
console.log(`\n${'─'.repeat(70)}`);
|
|
console.log(`📅 Calendar: ${calName} (${calId})`);
|
|
console.log('─'.repeat(70));
|
|
|
|
const deleted = await findDeletedEvents(calendar, calId);
|
|
console.log(`\n Found ${deleted.length} deleted events:\n`);
|
|
totalFound += deleted.length;
|
|
|
|
if (deleted.length === 0) {
|
|
console.log(' (none)');
|
|
continue;
|
|
}
|
|
|
|
// Group: recurring instances vs standalone
|
|
const standalone = deleted.filter(e => !e.recurringEventId);
|
|
const recurring = deleted.filter(e => !!e.recurringEventId);
|
|
|
|
// Track unique parent recurring events to avoid double-restoring
|
|
const restoredParents = new Set();
|
|
|
|
// Restore standalone events first
|
|
if (standalone.length > 0) {
|
|
console.log(` 📌 Standalone deleted events: ${standalone.length}`);
|
|
for (const event of standalone) {
|
|
const title = event.summary || '(no title)';
|
|
const start = event.start?.dateTime || event.start?.date || '?';
|
|
console.log(` → [${event.id}] "${title}" @ ${start}`);
|
|
|
|
const result = await restoreEvent(calendar, calId, event);
|
|
if (result.success) {
|
|
totalRestored++;
|
|
console.log(` ✅ ${DRY_RUN ? 'Would restore' : 'Restored'}: ${result.note}`);
|
|
} else {
|
|
totalFailed++;
|
|
console.log(` ❌ Failed: ${result.error}`);
|
|
}
|
|
results.push({ calendar: calName, id: event.id, title, start, ...result });
|
|
}
|
|
}
|
|
|
|
// Restore recurring event instances
|
|
if (recurring.length > 0) {
|
|
console.log(`\n 🔁 Cancelled recurring instances: ${recurring.length}`);
|
|
|
|
// Group by parent recurring event
|
|
const byParent = {};
|
|
for (const event of recurring) {
|
|
if (!byParent[event.recurringEventId]) {
|
|
byParent[event.recurringEventId] = [];
|
|
}
|
|
byParent[event.recurringEventId].push(event);
|
|
}
|
|
|
|
for (const [parentId, instances] of Object.entries(byParent)) {
|
|
console.log(`\n Parent: ${parentId} (${instances.length} cancelled instances)`);
|
|
|
|
// First try restoring the parent if it's deleted
|
|
if (!restoredParents.has(parentId)) {
|
|
try {
|
|
const parentEvent = await calendar.events.get({
|
|
calendarId: calId,
|
|
eventId: parentId,
|
|
});
|
|
|
|
if (parentEvent.data.status === 'cancelled') {
|
|
console.log(` ↳ Parent event is also cancelled, restoring parent...`);
|
|
if (!DRY_RUN) {
|
|
await calendar.events.patch({
|
|
calendarId: calId,
|
|
eventId: parentId,
|
|
requestBody: { status: 'confirmed' },
|
|
});
|
|
}
|
|
console.log(` ✅ Parent restored`);
|
|
restoredParents.add(parentId);
|
|
}
|
|
} catch (err) {
|
|
console.log(` ⚠️ Could not check parent: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Now restore each instance
|
|
for (const event of instances) {
|
|
const title = event.summary || '(from recurring)';
|
|
const start = event.originalStartTime?.dateTime || event.originalStartTime?.date || event.start?.dateTime || event.start?.date || '?';
|
|
console.log(` → Instance @ ${start}`);
|
|
|
|
const result = await restoreEvent(calendar, calId, event);
|
|
if (result.success) {
|
|
totalRestored++;
|
|
console.log(` ✅ ${DRY_RUN ? 'Would restore' : 'Restored'}: ${result.note}`);
|
|
} else {
|
|
totalFailed++;
|
|
console.log(` ❌ Failed: ${result.error}`);
|
|
}
|
|
results.push({ calendar: calName, id: event.id, title, start, ...result });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log(`\n${'='.repeat(70)}`);
|
|
console.log(' SUMMARY');
|
|
console.log('='.repeat(70));
|
|
console.log(` Total deleted events found: ${totalFound}`);
|
|
console.log(` ${DRY_RUN ? 'Would restore' : 'Restored'}: ${totalRestored}`);
|
|
console.log(` Failed: ${totalFailed}`);
|
|
console.log(DRY_RUN ? '\n ⚠️ This was a DRY RUN. Run without --dry-run to actually restore.' : '\n ✅ Restoration complete!');
|
|
console.log('='.repeat(70));
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('❌ Fatal error:', err);
|
|
process.exit(1);
|
|
});
|