diff --git a/src/summarization/build-ics.test.ts b/src/summarization/build-ics.test.ts new file mode 100644 index 0000000..d47d7ac --- /dev/null +++ b/src/summarization/build-ics.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { MeetingRow } from "../db/repositories/agenda.js"; +import { buildIcs } from "./build-ics.js"; + +const now = new Date("2026-06-12T00:00:00.000Z"); +const meeting = (over: Partial = {}): MeetingRow => ({ + id: 1, + title: "פגישת צוות", + startsAt: new Date("2026-06-15T11:00:00.000Z"), + owner: "דנה", + chat: "עבודה", + sourceMessageId: 9, + ...over, +}); + +describe("buildIcs", () => { + it("wraps events in a VCALENDAR with a VEVENT per dated meeting", () => { + const ics = buildIcs([meeting()], now); + expect(ics.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true); + expect(ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true); + expect(ics).toContain("BEGIN:VEVENT"); + expect(ics).toContain("UID:meeting-1@catchapp"); + expect(ics).toContain("DTSTART:20260615T110000Z"); + expect(ics).toContain("SUMMARY:פגישת צוות"); + expect(ics).toContain("DESCRIPTION:אחראי: דנה · צ׳אט: עבודה"); + }); + + it("skips undated meetings (a VEVENT needs a DTSTART)", () => { + const ics = buildIcs([meeting({ startsAt: null })], now); + expect(ics).not.toContain("BEGIN:VEVENT"); + }); + + it("escapes special characters per RFC 5545", () => { + const ics = buildIcs([meeting({ title: "פגישה; חשובה, מאוד" })], now); + expect(ics).toContain("SUMMARY:פגישה\\; חשובה\\, מאוד"); + }); +}); diff --git a/src/summarization/build-ics.ts b/src/summarization/build-ics.ts new file mode 100644 index 0000000..7089b75 --- /dev/null +++ b/src/summarization/build-ics.ts @@ -0,0 +1,55 @@ +import type { MeetingRow } from "../db/repositories/agenda.js"; + +// ── Local iCalendar (.ics) export (S8) ────────────────── +// +// The constitution forbids outbound third-party integrations ("nothing leaves +// the box"). So instead of syncing to Google Calendar, S7's local `meetings` +// are exported as a standard `.ics` file the user imports into ANY calendar +// themselves — nothing leaves the device automatically. This is a pure builder. + +/** Escape a text value per RFC 5545 (commas, semicolons, backslashes, newlines). */ +function escapeText(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +} + +/** Format a Date as an iCal UTC timestamp: YYYYMMDDTHHMMSSZ. */ +function icsStamp(d: Date): string { + return d + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}/, ""); +} + +/** + * Build a VCALENDAR string from meetings. Only meetings with a known `startsAt` + * become VEVENTs (an event needs a DTSTART); undated meetings are skipped — the + * agenda still shows them in-app. `now` is injected for deterministic tests. + * Pure — no IO, nothing leaves the box. + */ +export function buildIcs(meetings: MeetingRow[], now: Date): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Catchup//CatchApp//HE", + "CALSCALE:GREGORIAN", + ]; + const stamp = icsStamp(now); + for (const m of meetings) { + if (!m.startsAt) continue; + const desc = [m.owner ? `אחראי: ${m.owner}` : "", m.chat ? `צ׳אט: ${m.chat}` : ""] + .filter(Boolean) + .join(" · "); + lines.push( + "BEGIN:VEVENT", + `UID:meeting-${m.id}@catchapp`, + `DTSTAMP:${stamp}`, + `DTSTART:${icsStamp(m.startsAt)}`, + `SUMMARY:${escapeText(m.title)}`, + ...(desc ? [`DESCRIPTION:${escapeText(desc)}`] : []), + "END:VEVENT", + ); + } + lines.push("END:VCALENDAR"); + // RFC 5545 uses CRLF line endings. + return `${lines.join("\r\n")}\r\n`; +} diff --git a/src/web/handlers/agenda.ts b/src/web/handlers/agenda.ts index ffad0c1..50a6699 100644 --- a/src/web/handlers/agenda.ts +++ b/src/web/handlers/agenda.ts @@ -1,6 +1,7 @@ import type http from "node:http"; import { listMeetings, listTodos, setTodoDone } from "../../db/repositories/agenda.js"; import { listPeople } from "../../db/repositories/people.js"; +import { buildIcs } from "../../summarization/build-ics.js"; import type { ServerDeps } from "./context.js"; import { readJsonBody } from "./scopes.js"; @@ -47,6 +48,30 @@ export async function handleMeetings( } } +/** + * GET /api/meetings.ics — the LOCAL agenda as a downloadable iCalendar file (S8). + * The constitution-safe alternative to outbound calendar sync: the user imports + * this into any calendar themselves; nothing leaves the box automatically. + */ +export async function handleMeetingsIcs( + _url: URL, + res: http.ServerResponse, + deps: ServerDeps, +): Promise { + try { + const meetings = await listMeetings(deps.pool); + const ics = buildIcs(meetings, new Date()); + res.writeHead(200, { + "content-type": "text/calendar; charset=utf-8", + "content-disposition": 'attachment; filename="catchapp.ics"', + }); + res.end(ics); + } catch { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "Failed to export calendar." })); + } +} + /** * GET /api/todos — the checklist. * PATCH /api/todos/:id — toggle done {done:boolean}. CSRF-guarded by dispatchApi. diff --git a/src/web/public/app.js b/src/web/public/app.js index 1b050a2..b600de8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2408,7 +2408,11 @@ function paintAgenda() {
-

${icon("calendar", { size: 15 })} פגישות שנאספו

+
+

${icon("calendar", { size: 15 })} פגישות שנאספו

+ ייצוא ליומן (.ics) +
${buildCalendar()} ${buildAgendaTimeline()}
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 051e20b..060065b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3535,3 +3535,17 @@ button.srcchip:hover { filter: brightness(0.97); } @media (prefers-reduced-motion: reduce) { .ppl-row, .cl-box, .checklist__bar > b { transition: none; } } + +/* ── §6 local .ics export (S8) ──────────────────────────── */ +.duo__sechead { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ics-export { + font-size: var(--text-xs); + color: var(--accent-ink); + background: var(--accent-weak); + border: 1px solid var(--line); + border-radius: var(--radius-pill); + padding: 4px 10px; + text-decoration: none; + white-space: nowrap; +} +.ics-export:hover { background: var(--surface-2); } diff --git a/src/web/server.ts b/src/web/server.ts index 46e51de..5b9f28c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -6,7 +6,7 @@ import { currentUser } from "../auth/service.js"; import { DEFAULT_TENANT_ID, scopedPool } from "../db/tenant-context.js"; import { makeAdminRoutes } from "./admin-routes.js"; import { makeAuthRoutes } from "./auth-routes.js"; -import { handleMeetings, handlePeople, handleTodos } from "./handlers/agenda.js"; +import { handleMeetings, handleMeetingsIcs, handlePeople, handleTodos } from "./handlers/agenda.js"; import { handleAsk } from "./handlers/ask.js"; import type { ServerDeps } from "./handlers/context.js"; import { handleGroups } from "./handlers/groups.js"; @@ -187,6 +187,10 @@ function dispatchApi( void handlePeople(url, res, deps); return; } + if (req.method === "GET" && url.pathname === "/api/meetings.ics") { + void handleMeetingsIcs(url, res, deps); + return; + } if (req.method === "GET" && url.pathname === "/api/meetings") { void handleMeetings(url, res, deps); return;