config/bin/calendar

188 lines
5.5 KiB
JavaScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 = `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data/></d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="${startDate}" end="${endDate}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
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();