Initial commit: year overview app for Google Calendar ICS feeds
Local Node app that fetches a Google Calendar ICS URL server-side (to avoid browser CORS restrictions) and renders a two-year overview with each day split into morning/afternoon/evening segments, colored by emoji found at the start of event titles (configured in tags.json). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { buildOverview } = require('./days.js');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const TAGS_PATH = path.join(__dirname, 'tags.json');
|
||||
const INDEX_PATH = path.join(__dirname, 'public', 'index.html');
|
||||
|
||||
function readTags() {
|
||||
return JSON.parse(fs.readFileSync(TAGS_PATH, 'utf8'));
|
||||
}
|
||||
|
||||
function sendJSON(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function handleOverview(res, query) {
|
||||
const icsUrl = query.get('url');
|
||||
if (!icsUrl) return sendJSON(res, 400, { error: 'missing url parameter' });
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(icsUrl);
|
||||
} catch {
|
||||
return sendJSON(res, 400, { error: 'invalid url' });
|
||||
}
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return sendJSON(res, 400, { error: 'url must be http or https' });
|
||||
}
|
||||
|
||||
let icsText;
|
||||
try {
|
||||
const fetchRes = await fetch(icsUrl);
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
icsText = await fetchRes.text();
|
||||
} catch (err) {
|
||||
return sendJSON(res, 502, { error: `could not fetch calendar: ${err.message}` });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rangeStart = `${now.getFullYear()}-01-01`;
|
||||
const rangeEnd = `${now.getFullYear() + 1}-12-31`;
|
||||
|
||||
let overview;
|
||||
try {
|
||||
overview = buildOverview(icsText, readTags(), rangeStart, rangeEnd);
|
||||
} catch (err) {
|
||||
return sendJSON(res, 500, { error: `could not parse calendar: ${err.message}` });
|
||||
}
|
||||
|
||||
sendJSON(res, 200, { rangeStart, rangeEnd, days: overview });
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(fs.readFileSync(INDEX_PATH));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/tags.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(fs.readFileSync(TAGS_PATH));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/default-url') {
|
||||
return sendJSON(res, 200, { url: process.env.CALURL || null });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/overview') {
|
||||
await handleOverview(res, url.searchParams);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`http://localhost:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user