config/bin/calendar

280 lines
8.9 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';
// 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 <start-date> <end-date>');
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 <query> [days] — search events by name, default 30 days
if (!searchQuery) {
console.error('Usage: calendar search <query> [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 <YYYY-MM-DD>');
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 <command>');
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 <YYYY-MM-DD> — Events on a specific date');
console.log(' <YYYY-MM-DD> — Shorthand for date');
console.log(' range <from> <to> — Events in date range (YYYY-MM-DD or day offset)');
console.log(' search <query> [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();