Add calendar CLI tool, move creds to services.env
This commit is contained in:
parent
3674e0a96f
commit
2206332ff0
2 changed files with 198 additions and 10 deletions
20
TOOLS.md
20
TOOLS.md
|
|
@ -134,20 +134,20 @@ forgejo raw <endpoint> # Raw API call
|
||||||
|
|
||||||
## CalDAV Calendar Access
|
## CalDAV Calendar Access
|
||||||
|
|
||||||
Credentials stored in `.credentials/nextcloud.env`:
|
Helper script: `~/.openclaw/workspace/bin/calendar`
|
||||||
- URL: `https://nextcloud.cloonar.com`
|
|
||||||
- User: `moltbot@cloonar.com`
|
|
||||||
- Calendar: `personal_shared_by_dominik.polakovics@cloonar.com`
|
|
||||||
|
|
||||||
To fetch today's events:
|
|
||||||
```bash
|
```bash
|
||||||
source .credentials/nextcloud.env
|
calendar today # Today's events (default)
|
||||||
curl -s -X REPORT -u "$NEXTCLOUD_USER:$NEXTCLOUD_PASS" \
|
calendar tomorrow # Tomorrow's events
|
||||||
-H "Content-Type: application/xml" -H "Depth: 1" \
|
calendar week # Next 7 days
|
||||||
-d '<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="'$(date +%Y%m%d)'T000000Z" end="'$(date +%Y%m%d)'T235959Z"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>' \
|
calendar next # Next 14 days
|
||||||
"$NEXTCLOUD_URL/remote.php/dav/calendars/$NEXTCLOUD_USER/$CALDAV_CALENDAR/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Credentials: `.credentials/services.env` (NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASS, CALDAV_CALENDAR)
|
||||||
|
- Calendar: `personal_shared_by_dominik.polakovics@cloonar.com`
|
||||||
|
- Output: tab-separated (time, summary, location)
|
||||||
|
- Times shown in Vienna timezone
|
||||||
|
|
||||||
## AI News RSS (Hybrid Approach)
|
## AI News RSS (Hybrid Approach)
|
||||||
|
|
||||||
Helper script: `~/.openclaw/workspace/bin/ainews`
|
Helper script: `~/.openclaw/workspace/bin/ainews`
|
||||||
|
|
|
||||||
188
bin/calendar
Executable file
188
bin/calendar
Executable file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/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();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue