Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/summarization/build-ics.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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:פגישה\\; חשובה\\, מאוד");
});
});
55 changes: 55 additions & 0 deletions src/summarization/build-ics.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
25 changes: 25 additions & 0 deletions src/web/handlers/agenda.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<void> {
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.
Expand Down
6 changes: 5 additions & 1 deletion src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2408,7 +2408,11 @@ function paintAgenda() {
</div>
<div class="duo">
<section class="duo__col">
<h2 class="duo__sec">${icon("calendar", { size: 15 })} פגישות שנאספו</h2>
<div class="duo__sechead">
<h2 class="duo__sec">${icon("calendar", { size: 15 })} פגישות שנאספו</h2>
<a class="ics-export" href="/api/meetings.ics" download="catchapp.ics"
title="ייצוא הפגישות לקובץ יומן מקומי — שום דבר לא יוצא מהמכשיר">ייצוא ליומן (.ics)</a>
</div>
${buildCalendar()}
${buildAgendaTimeline()}
</section>
Expand Down
14 changes: 14 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
6 changes: 5 additions & 1 deletion src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down