#!/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';
// Parse date string (YYYY-MM-DD or natural offsets)
function parseDate(str) {
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const d = new Date(Date.UTC(+m[1], +m[2]-1, +m[3]));
d.setUTCHours(0, 0, 0, 0);
return d;
}
// Try as number of days offset
const n = parseInt(str);
if (!isNaN(n)) {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
d.setUTCDate(d.getUTCDate() + n);
return d;
}
return null;
}
let range;
let label = cmd;
const searchQuery = args.slice(1).join(' ').toLowerCase();
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;
case 'month':
range = getDateRange(0, 30);
break;
case 'range': {
// calendar range 2026-02-18 2026-02-25
const from = args[1] && parseDate(args[1]);
const to = args[2] && parseDate(args[2]);
if (!from || !to) {
console.error('Usage: calendar range ');
console.error(' Dates: YYYY-MM-DD or offset in days (e.g., 0 = today, 7 = in 7 days)');
process.exit(1);
}
const fmt = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
range = { start: fmt(from), end: fmt(to) };
label = `${args[1]} to ${args[2]}`;
break;
}
case 'search': {
// calendar search [days] — search events by name, default 30 days
if (!searchQuery) {
console.error('Usage: calendar search [days]');
console.error(' Searches event summaries/locations. Default: next 30 days.');
process.exit(1);
}
const days = parseInt(args[args.length - 1]);
const searchDays = (!isNaN(days) && args.length > 2) ? days : 90;
range = getDateRange(0, searchDays);
label = `search "${searchQuery}" (${searchDays} days)`;
break;
}
case 'date': {
// calendar date 2026-02-20
const d = args[1] && parseDate(args[1]);
if (!d) {
console.error('Usage: calendar date ');
process.exit(1);
}
const next = new Date(d);
next.setUTCDate(next.getUTCDate() + 1);
const fmt = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
range = { start: fmt(d), end: fmt(next) };
label = args[1];
break;
}
default:
// Try as date: calendar 2026-02-20
const asDate = parseDate(cmd);
if (asDate) {
const next = new Date(asDate);
next.setUTCDate(next.getUTCDate() + 1);
const fmt = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
range = { start: fmt(asDate), end: fmt(next) };
label = cmd;
break;
}
console.log('Usage: calendar ');
console.log(' today — Events for today (default)');
console.log(' tomorrow — Events for tomorrow');
console.log(' week — Next 7 days');
console.log(' next — Next 14 days');
console.log(' month — Next 30 days');
console.log(' date — Events on a specific date');
console.log(' — Shorthand for date');
console.log(' range — Events in date range (YYYY-MM-DD or day offset)');
console.log(' search [days] — Search events by name/location (default: 90 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);
}
let events = parseEvents(res.data);
// Filter by search query if searching
if (cmd === 'search' && searchQuery) {
const q = searchQuery.replace(/\s+\d+$/, ''); // strip trailing days number
events = events.filter(e =>
(e.summary || '').toLowerCase().includes(q) ||
(e.location || '').toLowerCase().includes(q) ||
(e.description || '').toLowerCase().includes(q)
);
}
if (events.length === 0) {
console.log(`No events (${label}).`);
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();