#!/usr/bin/env node // Calendar CLI — CalDAV client for Nextcloud calendar // Credentials from .credentials/services.env: // NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASS, CALDAV_CALENDAR const fs = require('fs'); const path = require('path'); const https = require('https'); // Load credentials const credFile = path.join(__dirname, '..', '.credentials', 'services.env'); const env = {}; if (fs.existsSync(credFile)) { fs.readFileSync(credFile, 'utf8').split('\n').forEach(l => { const m = l.match(/^([A-Z_]+)=(.+)$/); if (m) env[m[1]] = m[2].trim(); }); } const BASE_URL = env.NEXTCLOUD_URL; const USER = env.NEXTCLOUD_USER; const PASS = env.NEXTCLOUD_PASS; const CALENDAR = env.CALDAV_CALENDAR; if (!BASE_URL || !USER || !PASS || !CALENDAR) { console.error('Missing NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASS, or CALDAV_CALENDAR in .credentials/services.env'); process.exit(1); } function caldavRequest(startDate, endDate) { const url = new URL(`/remote.php/dav/calendars/${USER}/${CALENDAR}/`, BASE_URL); const auth = Buffer.from(`${USER}:${PASS}`).toString('base64'); const body = ` `; return new Promise((resolve, reject) => { const opts = { method: 'REPORT', hostname: url.hostname, port: url.port || 443, path: url.pathname, headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/xml', 'Depth': '1', }, }; const req = https.request(opts, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => resolve({ status: res.statusCode, data })); }); req.on('error', reject); req.write(body); req.end(); }); } function parseEvents(xmlData) { const events = []; const eventBlocks = xmlData.split('BEGIN:VEVENT'); for (let i = 1; i < eventBlocks.length; i++) { const block = eventBlocks[i].split('END:VEVENT')[0]; const get = (key) => { // Handle both folded and unfolded lines const regex = new RegExp(`${key}[^:]*:(.+?)(?:\\r?\\n(?! ))`, 's'); const m = block.match(regex); return m ? m[1].replace(/\r?\n\s/g, '').trim() : null; }; const summary = get('SUMMARY'); const dtstart = get('DTSTART'); const dtend = get('DTEND'); const location = get('LOCATION'); const description = get('DESCRIPTION'); if (summary) { events.push({ summary, dtstart, dtend, location, description }); } } // Sort by start time events.sort((a, b) => (a.dtstart || '').localeCompare(b.dtstart || '')); return events; } function formatDateTime(dt) { if (!dt) return ''; // Handle YYYYMMDD (all-day) if (/^\d{8}$/.test(dt)) { return `${dt.slice(0,4)}-${dt.slice(4,6)}-${dt.slice(6,8)} (all day)`; } // Handle YYYYMMDDTHHMMSSZ or YYYYMMDDTHHMMSS const m = dt.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/); if (m) { const d = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6])); // Convert to Vienna time return d.toLocaleString('de-AT', { timeZone: 'Europe/Vienna', dateStyle: 'short', timeStyle: 'short' }); } return dt; } function formatDateRange(start, end) { if (!start) return 'unknown time'; // All-day event if (/^\d{8}$/.test(start)) return 'all day'; const startStr = formatDateTime(start); if (!end) return startStr; // Same day — only show end time const endStr = formatDateTime(end); return `${startStr} – ${endStr.split(', ').pop() || endStr}`; } function getDateRange(offset, days) { const start = new Date(); start.setUTCHours(0, 0, 0, 0); start.setUTCDate(start.getUTCDate() + offset); const end = new Date(start); end.setUTCDate(end.getUTCDate() + days); const fmt = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, ''); return { start: fmt(start), end: fmt(end) }; } async function main() { const args = process.argv.slice(2); const cmd = args[0] || 'today'; let range; switch (cmd) { case 'today': range = getDateRange(0, 1); break; case 'tomorrow': range = getDateRange(1, 1); break; case 'week': range = getDateRange(0, 7); break; case 'next': range = getDateRange(0, 14); break; default: console.log('Usage: calendar [today|tomorrow|week|next]'); console.log(' today — Events for today (default)'); console.log(' tomorrow — Events for tomorrow'); console.log(' week — Events for the next 7 days'); console.log(' next — Events for the next 14 days'); process.exit(0); } try { const res = await caldavRequest(range.start, range.end); if (res.status !== 207 && res.status !== 200) { console.error(`CalDAV error: HTTP ${res.status}`); process.exit(1); } const events = parseEvents(res.data); if (events.length === 0) { console.log(`No events (${cmd}).`); return; } for (const e of events) { const time = formatDateRange(e.dtstart, e.dtend); const loc = e.location ? ` 📍 ${e.location}` : ''; console.log(`${time}\t${e.summary}${loc}`); } console.log(`\n${events.length} event(s).`); } catch (err) { console.error('Error:', err.message); process.exit(1); } } main();