diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 79060b9..0bcebcf 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -26,6 +26,9 @@ class SettingsOut(BaseModel): tz: str = "UTC" time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide jitsi_server: str = "" # blank => https://meet.jit.si/ + default_calendar: str = "" # calendar id new events default to (blank => first) + default_reminders_timed: list[int] = [] # minutes-before defaults for timed events + default_reminders_allday: list[int] = [] # minutes-before defaults for all-day events allowed_image_senders: list[str] = [] @@ -34,6 +37,9 @@ class SettingsPatch(BaseModel): tz: str | None = None time_format: str | None = None jitsi_server: str | None = None + default_calendar: str | None = None + default_reminders_timed: list[int] | None = None + default_reminders_allday: list[int] | None = None class QuotaOut(BaseModel): @@ -56,6 +62,10 @@ def add_allowed_image_sender(user: User, email: str) -> None: user.settings = {**user.settings, "allowed_image_senders": current} +def _int_list(value: object) -> list[int]: + return [int(x) for x in value] if isinstance(value, list) else [] + + def _settings_out(user: User) -> SettingsOut: s = user.settings return SettingsOut( @@ -63,6 +73,9 @@ def _settings_out(user: User) -> SettingsOut: tz=str(s.get("tz", "UTC")), time_format=str(s.get("time_format", "24")), jitsi_server=str(s.get("jitsi_server", "")), + default_calendar=str(s.get("default_calendar", "")), + default_reminders_timed=_int_list(s.get("default_reminders_timed")), + default_reminders_allday=_int_list(s.get("default_reminders_allday")), allowed_image_senders=sorted(allowed_image_senders(user)), ) @@ -92,6 +105,12 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) updates["time_format"] = body.time_format if body.jitsi_server is not None: updates["jitsi_server"] = body.jitsi_server.strip() + if body.default_calendar is not None: + updates["default_calendar"] = body.default_calendar + if body.default_reminders_timed is not None: + updates["default_reminders_timed"] = [int(x) for x in body.default_reminders_timed] + if body.default_reminders_allday is not None: + updates["default_reminders_allday"] = [int(x) for x in body.default_reminders_allday] user.settings = updates await db.commit() return _settings_out(user) diff --git a/dev-environment-setup.md b/dev-environment-setup.md index 930036c..a6e34e9 100644 --- a/dev-environment-setup.md +++ b/dev-environment-setup.md @@ -157,8 +157,8 @@ cd backend && alembic upgrade head && cd .. # migrations against 5433 # Terminal 1 — API uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend -# Terminal 2 — worker -arq workers.worker.WorkerSettings # from backend/ +# Terminal 2 — worker (sync supervisor + arq job consumers in one process) +cd backend && WORKER_ROLE=all python -m workers.run # Terminal 3 — frontend cd frontend && npm run dev # Vite on 5173, proxies /api -> 8000 @@ -194,8 +194,12 @@ reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate cd backend && alembic upgrade head api: uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend -worker: - cd backend && arq workers.worker.WorkerSettings +worker: ## run both sync + jobs in one process (dev) + cd backend && WORKER_ROLE=all python -m workers.run +worker-sync: ## run only the account-sync supervisor + cd backend && WORKER_ROLE=sync python -m workers.run +worker-jobs: ## run only the arq job consumers (interactive + bulk) + cd backend && WORKER_ROLE=jobs python -m workers.run web: cd frontend && npm run dev test: diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 4ed02ff..fb09e22 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -13,6 +13,7 @@ export const en = { "common.edit": "Edit", "common.close": "Close", "common.clear": "Clear", + "common.copy": "Copy", "common.back": "Back", "common.next": "Next", "common.signOut": "Sign out", @@ -226,6 +227,33 @@ export const en = { "settings.timeFormat": "Time format", "settings.timeFormat24": "24-hour (14:30)", "settings.timeFormat12": "12-hour (2:30 PM)", + "settings.calendars": "Calendars", + "settings.myCalendars": "My calendars", + "settings.calendarOptions": "Calendar options", + "settings.createCalendar": "Create calendar", + "settings.editCalendar": "Edit calendar", + "settings.export": "Export", + "settings.calendarDisplay": "Calendar display", + "settings.default": "Default", + "settings.defaultCalendar": "Default calendar", + "settings.firstCalendar": "First calendar", + "settings.defaultRemindersTimed": "Default reminders for timed events", + "settings.defaultRemindersAllday": "Default reminders for all-day events", + "settings.addReminder": "Add reminder", + "settings.name": "Name", + "settings.onlyYou": "Only you can see this calendar.", + "settings.publish": "Publish", + "settings.publishHint": "Share this calendar (read-only) with anyone.", + "settings.fullEventDetails": "Full event details", + "settings.freeBusy": "Free/busy information", + "settings.addAccountHint": "Connect an external calendar account.", + "settings.subscriptions": "Subscriptions", + "settings.subscriptionsHint": "Add a read-only calendar from an iCal (.ics) URL.", + "settings.subscribe": "Subscribe", + "settings.subscribeUrl": "iCal (.ics) URL", + "settings.subscribed": "Subscribed", + "settings.comingSoon": "Coming soon", + "settings.subscribeError": "Couldn't subscribe to that URL.", "settings.integrations": "Integrations", "settings.jitsi": "Jitsi Meet", "settings.jitsiHint": diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 683a5b0..37976c0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,7 @@ import type { AccountDetail, AccountOut, AttachmentOut, + CalendarOut, DiscoverResponse, DraftInput, DraftOut, @@ -167,12 +168,49 @@ export const api = { // --- settings --- settings: () => apiFetch("/settings"), patchSettings: ( - patch: Partial>, + patch: Partial< + Pick< + UserSettings, + | "thread_order" + | "tz" + | "time_format" + | "jitsi_server" + | "default_calendar" + | "default_reminders_timed" + | "default_reminders_allday" + > + >, ) => apiFetch("/settings", { method: "PATCH", body: JSON.stringify(patch) }), quota: () => apiFetch("/settings/quota"), folderCounts: () => apiFetch("/mailboxes/counts"), + // --- calendars (builtin calendar plugin) --- + calendars: () => apiFetch("/plugins/calendar/calendars"), + createCalendar: (body: { name: string; color: string }) => + apiFetch<{ id: string }>("/plugins/calendar/calendars", { + method: "POST", + body: JSON.stringify(body), + }), + patchCalendar: ( + id: string, + body: { name?: string; color?: string; publish?: "off" | "full" | "busy" }, + ) => + apiFetch<{ status: string; publish_token: string | null }>( + `/plugins/calendar/calendars/${id}`, + { method: "PATCH", body: JSON.stringify(body) }, + ), + deleteCalendar: (id: string) => + apiFetch<{ status: string }>(`/plugins/calendar/calendars/${id}`, { method: "DELETE" }), + exportCalendarUrl: (id: string) => `/api/plugins/calendar/calendars/${id}/export`, + publicCalendarUrl: (token: string) => + `${window.location.origin}/api/plugins/calendar/public/${token}`, + subscribeCalendar: (body: { url: string; name?: string; color?: string }) => + apiFetch<{ id: string; imported: number; name: string }>("/plugins/calendar/subscriptions", { + method: "POST", + body: JSON.stringify(body), + }), + // --- saved searches (virtual mailboxes) --- savedSearches: () => apiFetch("/saved-searches"), createSavedSearch: (name: string, query: string) => diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index be72adf..1555f83 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -148,11 +148,25 @@ export interface SessionInfo { current: boolean; } +export interface CalendarOut { + id: string; + name: string; + color: string; + kind: string; + read_only: boolean; + publish_token: string | null; + publish_busy: boolean; + subscription_url: string | null; +} + export interface UserSettings { thread_order: "asc" | "desc"; tz: string; time_format: "12" | "24"; jitsi_server: string; + default_calendar: string; + default_reminders_timed: number[]; + default_reminders_allday: number[]; allowed_image_senders: string[]; } diff --git a/frontend/src/plugins/bundleLoader.ts b/frontend/src/plugins/bundleLoader.ts index 002836c..6476a51 100644 --- a/frontend/src/plugins/bundleLoader.ts +++ b/frontend/src/plugins/bundleLoader.ts @@ -10,8 +10,19 @@ type Importer = (url: string) => Promise; const _cache = new Map>(); +// In dev, bust the browser's HTTP/module cache so an edited plugin bundle is picked +// up on reload. The backend sends `no-cache`, but a copy the browser cached *before* +// that header existed can still be served as "fresh" without revalidating — a unique +// per-page-load query sidesteps it. Computed once per module load so a bundle is still +// imported only once within a session (and the cache key stays the clean URL). Prod +// keeps the clean, cacheable URL. +const DEV_CACHE_BUST = import.meta.env.DEV ? `v=${Date.now()}` : ""; + // @vite-ignore keeps Vite from trying to resolve/transform the runtime URL. -const defaultImporter: Importer = (url) => import(/* @vite-ignore */ url); +const defaultImporter: Importer = (url) => { + const bustable = DEV_CACHE_BUST ? `${url}${url.includes("?") ? "&" : "?"}${DEV_CACHE_BUST}` : url; + return import(/* @vite-ignore */ bustable); +}; export function loadBundle(url: string, importer: Importer = defaultImporter): Promise { let pending = _cache.get(url); diff --git a/frontend/src/plugins/plugin-ui.css b/frontend/src/plugins/plugin-ui.css index d7b14ea..7770c37 100644 --- a/frontend/src/plugins/plugin-ui.css +++ b/frontend/src/plugins/plugin-ui.css @@ -9,7 +9,8 @@ */ .wm-root { - font: 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; color: rgb(var(--app-fg)); } @@ -126,3 +127,93 @@ font-size: 14px; color: rgb(var(--app-muted)); } + +/* ── Sidebar shell ───────────────────────────────────────────────────────── + * Shared left-panel template. Each class @applies the EXACT Tailwind utilities + * Mail's sidebar uses (frontend/src/routes/MailLayout.tsx), so the compiled CSS + * is byte-identical to Mail's — no hand-transcribed values that can drift. + * `.wm-side` also pins the base font context (Lato, 16px, line-height 1.5) to + * Mail's , because a plugin bundle's own root can change the inherited + * font size/family for anything that doesn't set its own (e.g. the theme glyph). + * An app supplies only: switcher label + menu, primary button, the scrollable + * body content (.wm-side-body), and the foot links. */ +.wm-side { + @apply flex flex-col border-r border-app-border bg-app-bg; + height: 100%; + font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 16px; + line-height: 1.5; +} +.wm-side-head { + @apply flex items-center justify-between pt-3 px-3 pb-1; +} +.wm-side-switch { + @apply relative; +} +.wm-side-switch > button { + @apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover; + border: 0; + background: none; + cursor: pointer; +} +.wm-side-menu { + @apply absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg; +} +.wm-side-menu button { + @apply flex w-full items-center px-3 py-1.5 text-left text-sm text-app-muted hover:bg-app-hover; + border: 0; + background: none; + cursor: pointer; +} +.wm-side-headicons { + @apply flex items-center gap-1; +} +.wm-side-iconbtn { + @apply inline-flex rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg; + border: 0; + background: none; + cursor: pointer; +} +.wm-side-themebtn { + @apply p-2; +} +.wm-side-primary { + @apply px-3 pb-2; +} +.wm-side-primary > button { + @apply flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90; + border: 0; + cursor: pointer; +} +.wm-side-body { + @apply flex-1 overflow-y-auto px-2 py-1 text-[15px]; + min-height: 0; +} +.wm-side-section { + @apply px-3 pt-3 pb-1 text-[12px] font-semibold uppercase tracking-wide text-app-faint; +} +.wm-side-nav { + @apply flex flex-col; + gap: 2px; +} +.wm-side-item { + @apply flex w-full items-center gap-1.5 rounded px-2.5 py-1 text-left text-app-fg hover:bg-app-hover hover:text-app-fg; + border: 0; + background: none; + cursor: pointer; +} +.wm-side-foot { + @apply border-t border-app-border p-2 text-[15px]; +} +.wm-side-foot button, +.wm-side-foot a { + @apply flex w-full items-center gap-2.5 rounded-lg py-2 px-3 text-app-muted hover:bg-app-hover hover:text-app-fg; + border: 0; + background: none; + cursor: pointer; + text-decoration: none; + text-align: left; +} +.wm-side-quota { + @apply mt-1 border-t border-app-border px-3 pt-2 pb-1 text-[13px] text-app-faint; +} diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 682b67d..a86158e 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -1,17 +1,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { QRCodeSVG } from "qrcode.react"; import { useEffect, useState } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { useT } from "../../i18n"; import { api, ApiError } from "../../lib/api"; +import type { CalendarOut } from "../../lib/types"; import { useMe } from "../../lib/auth"; import { useTheme } from "../../lib/theme"; import { setTimeFormat } from "../../lib/timeFormat"; import { PluginSlot } from "../../plugins/PluginSlot"; -const SECTIONS = ["profile", "security", "accounts", "appearance", "integrations"] as const; +const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const; type Section = (typeof SECTIONS)[number]; export function Settings() { @@ -45,6 +46,7 @@ export function Settings() { {section === "profile" && } {section === "security" && } {section === "accounts" && } + {section === "calendars" && } {section === "appearance" && } {section === "integrations" && } {/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts. @@ -648,3 +650,534 @@ function IntegrationsSection() { ); } + +const CAL_COLORS: [string, string][] = [ + ["Blue", "#3b82f6"], + ["Green", "#22c55e"], + ["Red", "#ef4444"], + ["Orange", "#f97316"], + ["Purple", "#a855f7"], + ["Teal", "#14b8a6"], + ["Pink", "#ec4899"], + ["Gray", "#6b7280"], +]; + +function SettingsToggle({ + checked, + onChange, + disabled = false, +}: { + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +const TZ_RAW: string[] = (() => { + const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] }; + try { + return intl.supportedValuesOf ? intl.supportedValuesOf("timeZone") : []; + } catch { + return []; + } +})(); +const TZ_LIST = TZ_RAW.length + ? TZ_RAW + : ["UTC", "Europe/London", "Europe/Warsaw", "America/New_York", "Asia/Tokyo"]; +const REMINDER_OPTS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]; + +function reminderText(m: number): string { + if (m === 0) return "At time of event"; + if (m % 1440 === 0) return `${m / 1440} day${m / 1440 > 1 ? "s" : ""} before`; + if (m % 60 === 0) return `${m / 60} hour${m / 60 > 1 ? "s" : ""} before`; + return `${m} minutes before`; +} + +function CalendarsSection() { + const t = useT(); + const [searchParams] = useSearchParams(); + const [tab, setTab] = useState<"calendars" | "options">("calendars"); + const [view, setView] = useState( + searchParams.get("create") === "1" ? "new" : null, + ); + const calendars = useQuery({ queryKey: ["calendars"], queryFn: api.calendars }); + const list = calendars.data ?? []; + + return ( +
+
+ {(["calendars", "options"] as const).map((tb) => ( + + ))} +
+ + {tab === "options" ? ( + + ) : view ? ( + setView(null)} /> + ) : ( + setView("new")} onEdit={setView} /> + )} +
+ ); +} + +function CalendarList({ + calendars, + onCreate, + onEdit, +}: { + calendars: CalendarOut[]; + onCreate: () => void; + onEdit: (c: CalendarOut) => void; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const [subUrl, setSubUrl] = useState(""); + const subscribe = useMutation({ + mutationFn: () => api.subscribeCalendar({ url: subUrl.trim() }), + onSuccess: () => { + setSubUrl(""); + queryClient.invalidateQueries({ queryKey: ["calendars"] }); + }, + }); + const subscriptions = calendars.filter((c) => c.subscription_url !== null); + const accounts: [string, string][] = [ + ["Google", "google"], + ["iCloud", "icloud"], + ["CalDAV", "caldav"], + ]; + return ( +
+

{t("settings.calendars")}

+ +
+ {calendars + .filter((c) => c.subscription_url === null) + .map((c) => ( +
+ + + +
+ ))} +
+ +
+

{t("settings.addAccount")}

+

{t("settings.addAccountHint")}

+
+ {accounts.map(([label]) => ( + + ))} +
+
+ +
+

{t("settings.subscriptions")}

+

{t("settings.subscriptionsHint")}

+ {subscriptions.length > 0 && ( +
+ {subscriptions.map((c) => ( +
+ +
+
{c.name}
+
+ {c.subscription_url} +
+
+ +
+ ))} +
+ )} +
+ setSubUrl(e.target.value)} + /> + +
+ {subscribe.isError && ( +

{t("settings.subscribeError")}

+ )} +
+
+ ); +} + +function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBack: () => void }) { + const t = useT(); + const queryClient = useQueryClient(); + const [calId, setCalId] = useState(calendar?.id ?? null); + const editing = calId !== null; + const [name, setName] = useState(calendar?.name ?? ""); + const [color, setColor] = useState(calendar?.color ?? CAL_COLORS[0][1]); + const [publishToken, setPublishToken] = useState(calendar?.publish_token ?? null); + const [publishBusy, setPublishBusy] = useState(calendar?.publish_busy ?? false); + const fullDetails = publishToken !== null && !publishBusy; + const freeBusy = publishToken !== null && publishBusy; + + const done = () => { + queryClient.invalidateQueries({ queryKey: ["calendars"] }); + onBack(); + }; + const save = useMutation({ + mutationFn: async () => { + if (calId) await api.patchCalendar(calId, { name: name.trim(), color }); + else await api.createCalendar({ name: name.trim(), color }); + }, + onSuccess: done, + }); + const del = useMutation({ mutationFn: () => api.deleteCalendar(calId!), onSuccess: done }); + + // Turning on a Publish option auto-creates the calendar first (create mode) so a + // shareable .ics link can be shown immediately, then flips the public feed on/off. + const setPublish = useMutation({ + mutationFn: async (mode: "off" | "full" | "busy") => { + let id = calId; + if (!id) { + const created = await api.createCalendar({ name: name.trim(), color }); + id = created.id; + setCalId(id); + } + const res = await api.patchCalendar(id, { publish: mode }); + return { mode, token: res.publish_token }; + }, + onSuccess: ({ mode, token }) => { + setPublishToken(mode === "off" ? null : token); + setPublishBusy(mode === "busy"); + queryClient.invalidateQueries({ queryKey: ["calendars"] }); + }, + }); + + const publicUrl = publishToken ? api.publicCalendarUrl(publishToken) : ""; + const linkRow = ( +
+ + {publicUrl} + + +
+ ); + + const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color) + ? CAL_COLORS + : [["Custom", color], ...CAL_COLORS]; + + return ( +
+
+ + › + + {editing ? t("settings.editCalendar") : t("settings.createCalendar")} + +
+ +
+

+ {editing ? t("settings.editCalendar") : t("settings.createCalendar")} +

+
+
+ {t("settings.color")} +
+ + +
+
+ +
+
+ +
+
+

{t("settings.publish")}

+

{t("settings.publishHint")}

+
+
+
+
+ {t("settings.fullEventDetails")} + setPublish.mutate(v ? "full" : "off")} + /> +
+ {fullDetails && linkRow} +
+
+
+ {t("settings.freeBusy")} + setPublish.mutate(v ? "busy" : "off")} + /> +
+ {freeBusy && linkRow} +
+
+
+ +
+ + + {editing && ( + + )} +
+
+ ); +} + +function ReminderList({ + label, + value, + onChange, +}: { + label: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useT(); + return ( +
+ {label} +
+ {value.map((m, i) => ( +
+ + +
+ ))} + +
+
+ ); +} + +function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) { + const t = useT(); + const queryClient = useQueryClient(); + const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings }); + const patch = useMutation({ + mutationFn: api.patchSettings, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), + }); + const s = settings.data; + const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + + // Detect and persist the browser time zone on first visit (backend default is UTC). + useEffect(() => { + if (s && (!s.tz || s.tz === "UTC") && browserTz !== "UTC") patch.mutate({ tz: browserTz }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [s?.tz]); + + return ( +
+
+

{t("settings.calendarDisplay")}

+ +
+ +
+

{t("settings.default")}

+
+ + patch.mutate({ default_reminders_timed: v })} + /> + patch.mutate({ default_reminders_allday: v })} + /> +
+
+
+ ); +} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0004_calendar_publish_subscribe.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0004_calendar_publish_subscribe.py new file mode 100644 index 0000000..bd0ed99 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0004_calendar_publish_subscribe.py @@ -0,0 +1,40 @@ +"""calendar: per-calendar publish token + free/busy flag + subscription url (SPEC §A) + +Revision ID: calendar0004 +Revises: calendar0003 +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "calendar0004" +down_revision: str | None = "calendar0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # publish_token: unguessable slug for the public read-only .ics feed (NULL = private). + # publish_busy: True => the public feed exposes free/busy blocks only, not details. + # subscription_url: source .ics URL for a subscribed (read-only) external calendar. + op.execute("ALTER TABLE plg_calendar_calendars ADD COLUMN publish_token text") + op.execute( + "ALTER TABLE plg_calendar_calendars " + "ADD COLUMN publish_busy boolean NOT NULL DEFAULT false" + ) + op.execute("ALTER TABLE plg_calendar_calendars ADD COLUMN subscription_url text") + op.execute( + "CREATE UNIQUE INDEX plg_calendar_publish_token_idx " + "ON plg_calendar_calendars (publish_token) WHERE publish_token IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS plg_calendar_publish_token_idx") + op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS subscription_url") + op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS publish_busy") + op.execute("ALTER TABLE plg_calendar_calendars DROP COLUMN IF EXISTS publish_token") diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py index 50f4cf2..4596b6d 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py @@ -8,6 +8,7 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars from __future__ import annotations +import base64 import json import logging import uuid @@ -15,7 +16,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse, Response from sqlalchemy import text from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +30,7 @@ from webmail_plugin_calendar.schemas import ( EditFollowingIn, EventIn, EventPatch, + SubscribeIn, ) router = APIRouter() @@ -66,6 +68,69 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non # --- calendars --------------------------------------------------------------- +def _make_publish_token(user_id: uuid.UUID, calendar_id: uuid.UUID) -> str: + """Vault-seal {user_id, calendar_id} so the public /public/{token} feed can open + the right RLS session without exposing the ids or needing auth.""" + payload = json.dumps({"u": str(user_id), "c": str(calendar_id)}).encode() + return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode().rstrip("=") + + +def _read_publish_token(token: str) -> tuple[uuid.UUID, uuid.UUID] | None: + try: + pad = "=" * (-len(token) % 4) + data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token + pad))) + return uuid.UUID(data["u"]), uuid.UUID(data["c"]) + except Exception: + return None + + +def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes: + """Build a VCALENDAR .ics for a calendar's base events (busy_only strips detail).""" + from icalendar import Calendar as ICalendar + from icalendar import Event as IEvent + from icalendar.prop import vRecur + + cal = ICalendar() + cal.add("prodid", "-//Webmail//Calendar//EN") + cal.add("version", "2.0") + cal.add("x-wr-calname", name) + for r in rows: + ev = IEvent() + ev.add("uid", r["ical_uid"]) + ev.add("dtstamp", datetime.now(UTC)) + ev.add("dtstart", _aware(r["dtstart"])) # type: ignore[arg-type] + if r["dtend"]: + ev.add("dtend", _aware(r["dtend"])) # type: ignore[arg-type] + if busy_only: + ev.add("summary", "Busy") + else: + ev.add("summary", r["summary"] or "") + if r.get("description"): + ev.add("description", r["description"]) + if r.get("location"): + ev.add("location", r["location"]) + if r["rrule"]: + ev.add("rrule", vRecur.from_ical(str(r["rrule"]))) + cal.add_component(ev) + return cal.to_ical() + + +async def _calendar_event_rows(s: AsyncSession, calendar_id: uuid.UUID) -> list[dict[str, object]]: + return [ + dict(r) + for r in ( + await s.execute( + text( + "SELECT ical_uid, summary, description, location, dtstart, dtend, all_day, " + "rrule FROM plg_calendar_events " + "WHERE calendar_id = :id AND recurrence_id IS NULL" + ), + {"id": str(calendar_id)}, + ) + ).mappings().all() + ] + + @router.get("/calendars") async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]: async with _SDK.session(user_id) as s: @@ -73,8 +138,8 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]: rows = ( await s.execute( text( - "SELECT id, name, color, kind, read_only FROM plg_calendar_calendars " - "ORDER BY name" + "SELECT id, name, color, kind, read_only, publish_token, publish_busy, " + "subscription_url FROM plg_calendar_calendars ORDER BY name" ) ) ).mappings().all() @@ -85,6 +150,9 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]: "color": r["color"], "kind": r["kind"], "read_only": r["read_only"], + "publish_token": r["publish_token"], + "publish_busy": r["publish_busy"], + "subscription_url": r["subscription_url"], } for r in rows ] @@ -111,19 +179,39 @@ async def create_calendar(body: CalendarIn, user_id: CurrentUserId) -> dict[str, @router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)]) async def patch_calendar( calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId -) -> dict[str, str]: +) -> dict[str, str | None]: fields = body.model_dump(exclude_unset=True) - if not fields: + # `publish` is an action, not a column: off => unpublish, full/busy => publish. + publish = fields.pop("publish", None) + if not fields and publish is None: return {"status": "noop"} - set_sql = ", ".join(f"{k} = :{k}" for k in fields) async with _SDK.session(user_id) as s: + set_parts = [f"{k} = :{k}" for k in fields] + params: dict[str, object | None] = {**fields, "id": str(calendar_id)} + token: str | None = None + if publish is not None: + if publish == "off": + set_parts += ["publish_token = NULL"] + elif publish in ("full", "busy"): + existing = ( + await s.execute( + text("SELECT publish_token FROM plg_calendar_calendars WHERE id = :id"), + {"id": str(calendar_id)}, + ) + ).scalar_one_or_none() + token = existing or _make_publish_token(user_id, calendar_id) + set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"] + params["ptok"] = token + params["pbusy"] = publish == "busy" + else: + raise HTTPException(status_code=400, detail="bad_publish") result = await s.execute( - text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"), - {**fields, "id": str(calendar_id)}, + text(f"UPDATE plg_calendar_calendars SET {', '.join(set_parts)} WHERE id = :id"), + params, ) if result.rowcount == 0: raise HTTPException(status_code=404, detail="calendar_not_found") - return {"status": "ok"} + return {"status": "ok", "publish_token": token} @router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)]) @@ -137,6 +225,103 @@ async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dic return {"status": "ok"} +@router.get("/calendars/{calendar_id}/export") +async def export_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> Response: + """Download a calendar's events as a standard .ics file (VCALENDAR).""" + async with _SDK.session(user_id) as s: + name = ( + await s.execute( + text("SELECT name FROM plg_calendar_calendars WHERE id = :id"), + {"id": str(calendar_id)}, + ) + ).scalar_one_or_none() + if name is None: + raise HTTPException(status_code=404, detail="calendar_not_found") + rows = await _calendar_event_rows(s, calendar_id) + + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(name)) or "calendar" + return Response( + content=_calendar_ics(name, rows, busy_only=False), + media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{safe}.ics"'}, + ) + + +@router.post("/subscriptions", dependencies=[Depends(require_csrf)]) +async def subscribe_calendar(body: SubscribeIn, user_id: CurrentUserId) -> dict[str, str | int]: + """Subscribe to an external .ics URL: fetch it (SSRF-guarded), then import its + events into a new read-only calendar. One-time import (re-sync is A3).""" + from urllib.parse import urlparse + + from icalendar import Calendar as ICalendar + + raw = await _SDK.http_get(body.url) + if raw is None: + raise HTTPException(status_code=400, detail="fetch_failed") + try: + ics = ICalendar.from_ical(raw) + except Exception as exc: # noqa: BLE001 - malformed feed + raise HTTPException(status_code=400, detail="bad_ics") from exc + + name = body.name.strip() or str(ics.get("x-wr-calname") or "") or urlparse(body.url).netloc + events = list(ics.walk("VEVENT")) + async with _SDK.session(user_id) as s: + try: + cal_id = ( + await s.execute( + text( + "INSERT INTO plg_calendar_calendars " + "(user_id, name, color, kind, read_only, subscription_url) " + "VALUES (:u, :n, :c, 'ics', true, :url) RETURNING id" + ), + {"u": str(user_id), "n": name, "c": body.color, "url": body.url}, + ) + ).scalar_one() + except IntegrityError as exc: + raise HTTPException(status_code=409, detail="calendar_exists") from exc + count = 0 + for comp in events: + start = comp.get("dtstart") + if start is None: + continue + dt = start.dt + all_day = not isinstance(dt, datetime) + if not isinstance(dt, datetime): + dt = datetime(dt.year, dt.month, dt.day) + dtstart = _aware(dt) + end = comp.get("dtend") + dtend = None + if end is not None: + ed = end.dt + if not isinstance(ed, datetime): + ed = datetime(ed.year, ed.month, ed.day) + dtend = _aware(ed) + rrule = comp.get("rrule") + rrule_str = rrule.to_ical().decode() if rrule is not None else None + await s.execute( + text( + "INSERT INTO plg_calendar_events " + "(user_id, calendar_id, ical_uid, summary, description, location, dtstart, " + " dtend, all_day, rrule) VALUES " + "(:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :rrule)" + ), + { + "u": str(user_id), + "cal": str(cal_id), + "uid": str(comp.get("uid") or f"{uuid.uuid4()}@import"), + "sum": str(comp.get("summary") or ""), + "desc": str(comp.get("description") or ""), + "loc": str(comp.get("location") or ""), + "start": dtstart, + "end": dtend, + "allday": all_day, + "rrule": rrule_str, + }, + ) + count += 1 + return {"id": str(cal_id), "imported": count, "name": name} + + # --- events ------------------------------------------------------------------ @router.get("/events") @@ -144,8 +329,21 @@ async def list_events( user_id: CurrentUserId, start: datetime = Query(...), end: datetime = Query(...), + q: str = Query(""), ) -> dict[str, object]: + """List events overlapping [start, end). With ``q`` set, only events whose + summary/location/description match (case-insensitive) are returned — used by + the calendar search panel over a wide date window.""" range_start, range_end = _aware(start), _aware(end) + params: dict[str, object] = {"end": range_end} + where = "WHERE e.dtstart < :end" + term = q.strip() + if term: + where += ( + " AND (e.summary ILIKE :q OR e.location ILIKE :q " + "OR COALESCE(e.description, '') ILIKE :q)" + ) + params["q"] = f"%{term}%" async with _SDK.session(user_id) as s: rows = ( await s.execute( @@ -158,9 +356,9 @@ async def list_events( "COALESCE(e.color, c.color) AS color " "FROM plg_calendar_events e " "JOIN plg_calendar_calendars c ON c.id = e.calendar_id " - "WHERE e.dtstart < :end" + f"{where}" ), - {"end": range_end}, + params, ) ).mappings().all() base = [ @@ -498,6 +696,33 @@ async def delete_event( return {"status": "ok"} +# --- public read-only .ics feed (published calendars; no login) --------------- + +@router.get("/public/{token}") +async def public_calendar(token: str) -> Response: + parsed = _read_publish_token(token) + if parsed is None: + raise HTTPException(status_code=404, detail="not_found") + user_id, cal_id = parsed + async with _SDK.session(user_id) as s: + row = ( + await s.execute( + text( + "SELECT name, publish_token, publish_busy " + "FROM plg_calendar_calendars WHERE id = :id" + ), + {"id": str(cal_id)}, + ) + ).mappings().first() + if row is None or row["publish_token"] is None: + raise HTTPException(status_code=404, detail="not_found") + rows = await _calendar_event_rows(s, cal_id) + return Response( + content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]), + media_type="text/calendar", + ) + + # --- RSVP (public: guests respond from the invitation email, no login) -------- def _rsvp_page(message: str) -> str: @@ -553,4 +778,10 @@ async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse: async def bundle(filename: str) -> FileResponse: if filename != "page.js": raise HTTPException(status_code=404) - return FileResponse(_UI / filename, media_type="text/javascript") + # `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap), + # so an edited bundle is picked up on the next page reload instead of a stale copy. + return FileResponse( + _UI / filename, + media_type="text/javascript", + headers={"Cache-Control": "no-cache"}, + ) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py index 5ff4f99..5305108 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py @@ -16,6 +16,13 @@ class CalendarIn(BaseModel): class CalendarPatch(BaseModel): name: str | None = None color: str | None = None + publish: str | None = None # "off" | "full" | "busy" — public .ics feed control + + +class SubscribeIn(BaseModel): + url: str # source .ics URL to subscribe to (read-only external calendar) + name: str = "" # optional calendar name (defaults to the feed's title / host) + color: str = "#6b7280" class Attendee(BaseModel): diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 9f1a448..f7482b6 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -20,7 +20,12 @@ let HOUR12 = false; // Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US. const dateLocale = () => (HOUR12 ? "en-US" : "en-GB"); const pad2 = (n) => String(n).padStart(2, "0"); -const SIDEBAR_ICON = ``; +// Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly. +const HIDE_ICON = ``; +const SHOW_ICON = ``; +const SETTINGS_ICON = ``; +const LOGOUT_ICON = ``; +const COMPOSE_ICON = ``; const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾"); const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 }); // A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown). @@ -186,61 +191,82 @@ function tzEntries(current) { } const CSS = ` -.cal-app{display:flex;flex-direction:column;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font:14px system-ui,-apple-system,sans-serif} +.cal-app{display:flex;height:100vh;position:relative;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px} +.cal-maincol{flex:1;min-width:0;display:flex;flex-direction:column} +.cal-mainarea{flex:1;min-height:0;display:flex} .cal-topbar{display:flex;align-items:center;gap:10px;height:48px;padding:0 12px;flex:0 0 auto} -.cal-body{display:flex;flex:1;min-height:0} -.cal-left{flex:0 0 auto;border-right:1px solid rgb(var(--app-border));display:flex;flex-direction:column;overflow:auto} -.cal-lefthead{display:flex;align-items:center;gap:4px;padding:8px 8px 2px} +/* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css), + which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */ +.cal-secrow{display:flex;align-items:center;justify-content:space-between} +.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px} +.cal-secrow:hover .cal-secadd{opacity:1} +.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} .cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10} .cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent} .cal-resizer:hover::after{background:#3b82f6} -.cal-themebtn{font-size:15px;line-height:1} .cal-main{flex:1;min-width:0;display:flex;flex-direction:column} .cal-right{width:320px;flex:0 0 auto;border-left:1px solid rgb(var(--app-border));overflow:auto} +/* search results panel */ +.cal-srch{font-size:13px;padding-bottom:24px} +.cal-srch-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 6px;color:rgb(var(--app-muted))} +.cal-srch-more{padding:4px 14px 14px;border-bottom:1px solid rgb(var(--app-border))} +.cal-srch-more .cal-btn{background:rgb(var(--app-surface))} +.cal-srch-month{padding:16px 14px 8px;font-size:19px;font-weight:600} +.cal-srch-month span{color:rgb(var(--app-muted));font-weight:400} +.cal-srch-day{display:flex;gap:12px;padding:8px 14px} +.cal-srch-dt{width:40px;flex:0 0 auto;text-align:left;position:relative} +.cal-srch-dt .dow{display:block;font-size:11px;color:rgb(var(--app-faint));letter-spacing:.03em} +.cal-srch-dt .dnum{display:block;font-size:18px;font-weight:500} +.cal-srch-dt.today .dnum,.cal-srch-dt.today .dow{color:#3b82f6} +.cal-srch-evs{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;justify-content:center} +.cal-srch-ev{background:rgb(var(--app-hover));border-left:3px solid #3b82f6;border-radius:5px;padding:6px 9px;cursor:pointer} +.cal-srch-ev:hover{background:rgb(var(--app-active))} +.cal-srch-ev .t{display:flex;justify-content:space-between;gap:8px} +.cal-srch-ev .t b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cal-srch-ev .t span{color:rgb(var(--app-muted));flex:0 0 auto} +.cal-srch-ev .loc{color:rgb(var(--app-muted));overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.cal-srch-none{color:rgb(var(--app-faint));padding:6px 0} +.cal-srch-today{color:#3b82f6;font-size:11px;font-weight:600;letter-spacing:.04em;text-align:right;padding:0 14px} .cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto} .cal-view{flex:1;min-height:0;overflow:auto;position:relative} .cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))} .cal-btn-primary{background:#3b82f6;color:#fff;flex:1} -.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:6px;display:inline-flex} +.cal-iconbtn{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:6px;border-radius:4px;display:inline-flex} .cal-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} -.cal-switch{position:relative} -.cal-switch>button{display:flex;align-items:center;gap:6px;border:0;background:none;color:rgb(var(--app-fg));font-weight:600;font-size:15px;cursor:pointer;padding:4px 6px;border-radius:6px} -.cal-switch>button:hover{background:rgb(var(--app-hover))} -.cal-menu{position:absolute;left:0;top:100%;margin-top:4px;min-width:160px;background:rgb(var(--app-elevated));border:1px solid rgb(var(--app-border));border-radius:8px;padding:4px;z-index:30;box-shadow:0 6px 20px rgba(0,0,0,.3)} -.cal-menu button{display:block;width:100%;text-align:left;border:0;background:none;color:rgb(var(--app-muted));padding:6px 10px;border-radius:6px;cursor:pointer;font:inherit} -.cal-menu button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} -.cal-search{flex:1;max-width:520px;margin:0 auto;padding:7px 12px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg))} +.cal-searchbox{flex:1;max-width:520px;margin:0 auto;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))} +.cal-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto} +.cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit} .cal-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px} -.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 16px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px} +.cal-seg button{border:0;background:none;color:rgb(var(--app-muted));padding:5px 26px;border-radius:7px;cursor:pointer;font:inherit;font-size:13px} .cal-seg button.on{background:rgb(var(--app-elevated));color:rgb(var(--app-fg));box-shadow:0 1px 3px rgba(0,0,0,.35)} .cal-title{font-size:18px;font-weight:600} .cal-title span{color:rgb(var(--app-muted));font-weight:400} -.cal-mini{padding:10px} +.cal-mini{padding:6px 4px 12px} .cal-mini-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;font-size:12px;text-align:center} .cal-mini-grid .h{color:rgb(var(--app-faint));padding:3px 0} -.cal-mini-grid button{border:0;background:none;color:rgb(var(--app-fg));padding:4px 0;border-radius:50%;cursor:pointer;font:inherit} +.cal-mini-grid button{width:26px;height:26px;margin:1px auto;display:flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-fg));border-radius:50%;cursor:pointer;font:inherit;font-size:14px} .cal-mini-grid button:hover{background:rgb(var(--app-hover))} .cal-mini-grid button.out{color:rgb(var(--app-faint))} .cal-mini-grid button.today{background:#3b82f6;color:#fff} -.cal-section{padding:10px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;color:rgb(var(--app-faint))} -.cal-callist label{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer} -.cal-foot{margin-top:auto;border-top:1px solid rgb(var(--app-border));padding:8px} -.cal-foot button{display:flex;align-items:center;gap:10px;width:100%;border:0;background:none;color:rgb(var(--app-muted));padding:8px 12px;border-radius:8px;cursor:pointer;font:inherit;font-size:15px;text-align:left} -.cal-foot button:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))} /* month */ -.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:minmax(0,1fr);height:100%} -.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:11px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))} +.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%} +.cal-month .dow{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px 8px;font-size:13px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))} .cal-cell{border-bottom:1px solid rgb(var(--app-border));border-right:1px solid rgb(var(--app-border));padding:4px;min-height:0;overflow:hidden;cursor:pointer} -.cal-cell .n{font-size:15px;color:rgb(var(--app-fg));font-weight:500} +.cal-cell .n{display:flex;align-items:center;gap:4px;font-size:15px;color:rgb(var(--app-fg));font-weight:500} +.cal-cell .num{display:inline-flex;align-items:center;justify-content:center;min-width:25px;height:25px;border-radius:50%;box-sizing:border-box} +.cal-cell .mtag{color:rgb(var(--app-muted));font-weight:400;font-size:14px} .cal-cell.out .n{color:rgb(var(--app-faint))} +.cal-cell.out .mtag{color:rgb(var(--app-faint))} +.cal-cell:not(.out){background:rgba(255,255,255,.03)} .cal-cell.today{background:rgba(59,130,246,.06)} -.cal-cell.today .n{background:#3b82f6;color:#fff;border-radius:50%;width:25px;height:25px;display:inline-flex;align-items:center;justify-content:center} -.cal-ev{font-size:12px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))} +.cal-cell.today .num{background:#3b82f6;color:#fff} +.cal-ev{font-size:14px;padding:1px 4px;border-radius:4px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-left:3px solid #3b82f6;background:rgb(var(--app-hover))} +.cal-ev.srch-hl,.cal-tev.srch-hl{outline:2px solid #3b82f6;outline-offset:-1px;background:rgba(59,130,246,.28)} /* timegrid */ .cal-tg{display:grid;grid-template-columns:56px 1fr} .cal-tg-head{position:sticky;top:0;background:rgb(var(--app-bg));z-index:5;display:grid;grid-template-columns:56px 1fr;border-bottom:1px solid rgb(var(--app-border))} .cal-tg-days{display:grid} -.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:12px;color:rgb(var(--app-muted))} +.cal-tg-days .d{padding:6px 8px;border-left:1px solid rgb(var(--app-border));font-size:14px;color:rgb(var(--app-muted))} .cal-tg-days .d b{font-size:18px;color:rgb(var(--app-fg));display:block;font-weight:600} .cal-tg-days .d.today{color:#3b82f6;border-bottom:2px solid #3b82f6;margin-bottom:-1px} .cal-tg-days .d.today .num{background:#3b82f6;color:#fff;border-radius:50%;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:16px} @@ -251,7 +277,7 @@ const CSS = ` .cal-col{border-left:1px solid rgb(var(--app-border));position:relative} .cal-col.today{background:rgba(59,130,246,.06)} .cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))} -.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:12px;overflow:hidden;cursor:pointer;box-sizing:border-box} +.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box} .cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4} /* right agenda */ .cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))} @@ -356,9 +382,13 @@ export default function mount(el, ctx) { composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId } eventPopover: null, // null | { ev, rect } confirm: null, // null | { ev } — delete confirmation modal + search: null, // null | { q, from:Date, to:Date, results:[], loading:bool } me: "", quota: "", jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) + defaultCalendar: "", // preferred calendar for new events (from settings) + defaultRemTimed: [], // default reminder minutes for timed events + defaultRemAllday: [], // default reminder minutes for all-day events apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }], }; @@ -406,10 +436,32 @@ export default function mount(el, ctx) { }; render(); if (editEv) prefillForm(editEv); + else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults const first = el.querySelector("[data-f=summary]"); if (first) first.focus(); } + // Populate the reminders section with a list of minutes-before values. + function applyDefaultReminders(mins) { + const box = el.querySelector("[data-reminders]"); + if (!box || !mins || !mins.length) return; + mins.forEach((m) => { + const wrap = document.createElement("div"); + wrap.innerHTML = reminderRow(); + const row = wrap.firstElementChild; + const sel = row.querySelector("[data-rem]"); + if (![...sel.options].some((o) => o.value === String(m))) { + const o = document.createElement("option"); + o.value = String(m); + o.textContent = m === 0 ? "At time of event" : `${m} minutes before`; + sel.appendChild(o); + } + sel.value = String(m); + row.querySelector("[data-act=rm-reminder]").addEventListener("click", () => row.remove()); + box.appendChild(row); + }); + } + function prefillForm(ev) { const q = (s) => el.querySelector(s); q("[data-f=summary]").value = ev.summary || ""; @@ -547,6 +599,131 @@ export default function mount(el, ctx) { await reload(); } + // ---- search ---------------------------------------------------------------- + const SEARCH_WINDOW_DAYS = 540; // ± window; "further back/forward" extends by this + let searchTimer = null; + + async function fetchSearch() { + const s = state.search; + if (!s || !s.q.trim()) return; + s.loading = true; + try { + const res = await api.pluginFetch( + `/events?start=${encodeURIComponent(iso(s.from))}&end=${encodeURIComponent(iso(s.to))}&q=${encodeURIComponent(s.q.trim())}`); + s.results = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) })); + } catch { s.results = []; } + s.loading = false; + renderSearchPanel(); + } + + function renderSearchPanel() { + const panel = el.querySelector(".cal-right"); + if (!panel) { render(); return; } // panel not mounted yet → full render + panel.innerHTML = searchPanelHtml(); + wireSearchPanel(); + } + + function closeSearch() { + if (searchTimer) clearTimeout(searchTimer); + state.search = null; + render(); + } + + // Toggle a highlight on the calendar block(s) matching a hovered search result, + // only if that occurrence is drawn in the currently displayed view. + function highlightCalendarEvent(id, start, on) { + el.querySelectorAll(`.cal-view [data-ev="${id}"][data-start="${start}"]`) + .forEach((n) => n.classList.toggle("srch-hl", on)); + } + + async function openSearchResult(ev) { + const gs = startOfWeek(startOfMonth(state.anchor)); + const visibleNow = state.view === "month" && ev._s >= gs && ev._s < addDays(gs, 42); + if (!visibleNow) { + state.view = "month"; + state.anchor = startOfMonth(ev._s); + await reload(); + } + const block = el.querySelector(`.cal-view [data-ev="${ev.id}"][data-start="${ev.start}"]`); + if (block) { openEvent(ev, block); return; } + // Not drawn (e.g. its calendar is hidden) — still show details near the view. + const view = el.querySelector(".cal-view"); + const r = view ? view.getBoundingClientRect() : { top: 100, left: 300 }; + state.eventPopover = { ev, rect: { top: r.top + 24, left: r.left + 40, right: r.left + 90, bottom: r.top + 44 } }; + render(); + } + + function srchEventHtml(ev) { + return `
+
${esc(ev.summary || "(no title)")}${ev.all_day ? "All day" : hhmm(ev._s)}
+ ${ev.location ? `
${esc(ev.location)}
` : ""} +
`; + } + + function searchPanelHtml() { + const s = state.search; + const fromStr = s.from.toLocaleDateString(dateLocale()); + const toStr = s.to.toLocaleDateString(dateLocale()); + if (s.loading && !s.results.length) { + return `
Searching…
`; + } + // Group results by day; always include today's row (marked TODAY), like the mockup. + const byDay = new Map(); + s.results.forEach((ev) => { + const key = iso(startOfDay(ev._s)); + if (!byDay.has(key)) byDay.set(key, { date: startOfDay(ev._s), events: [] }); + byDay.get(key).events.push(ev); + }); + const todayKey = iso(startOfDay(new Date())); + if (!byDay.has(todayKey)) byDay.set(todayKey, { date: startOfDay(new Date()), events: [] }); + const days = [...byDay.values()].sort((a, b) => a.date - b.date); + let rows = ""; + let curMonth = null; + days.forEach((day) => { + const mk = day.date.getFullYear() * 12 + day.date.getMonth(); + if (mk !== curMonth) { + curMonth = mk; + rows += `
${MONTHS[day.date.getMonth()]} ${day.date.getFullYear()}
`; + } + const isToday = sameDay(day.date, new Date()); + const evs = day.events.slice().sort((a, b) => a._s - b._s); + rows += `${isToday ? `
TODAY
` : ""} +
+
${DAYNAMES[(day.date.getDay() + 6) % 7].toUpperCase()}${day.date.getDate()}
+
${evs.length ? evs.map(srchEventHtml).join("") : `
No events
`}
+
`; + }); + return `
+
Showing results from ${esc(fromStr)}
+
+ ${rows} +
Showing results up to ${esc(toStr)}
+
+
`; + } + + function wireSearchPanel() { + const panel = el.querySelector(".cal-right"); + if (!panel || !state.search) return; + panel.querySelectorAll("[data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch)); + panel.querySelector("[data-act=search-back]")?.addEventListener("click", () => { + state.search.from = addDays(state.search.from, -SEARCH_WINDOW_DAYS); fetchSearch(); + }); + panel.querySelector("[data-act=search-fwd]")?.addEventListener("click", () => { + state.search.to = addDays(state.search.to, SEARCH_WINDOW_DAYS); fetchSearch(); + }); + panel.querySelectorAll("[data-srch-ev]").forEach((node) => { + const id = node.getAttribute("data-srch-ev"); + const start = node.getAttribute("data-srch-start"); + node.addEventListener("mouseenter", () => highlightCalendarEvent(id, start, true)); + node.addEventListener("mouseleave", () => highlightCalendarEvent(id, start, false)); + node.addEventListener("click", () => { + const ev = (state.search.results || []).find((x) => x.id === id && x.start === start); + if (ev) openSearchResult(ev); + }); + }); + } + // Replace the Video-call button/link in place (no full re-render → keeps form input). function fillVideo(link) { if (!state.composing) return; @@ -636,11 +813,13 @@ export default function mount(el, ctx) { // ---- render ---------------------------------------------------------------- function render() { el.innerHTML = `
- ${topbar()} -
- ${state.leftCollapsed ? "" : `
${leftPanel()}
`} -
${mainHead()}
${viewBody()}
- ${state.rightOpen ? `
${agenda()}
` : ""} + ${state.leftCollapsed ? "" : `
${leftPanel()}
`} +
+ ${topbar()} +
+
${mainHead()}
${viewBody()}
+ ${state.search ? `
${searchPanelHtml()}
` : (state.rightOpen ? `
${agenda()}
` : "")} +
${state.composing ? formHtml() : ""} ${state.eventPopover ? eventPopoverHtml() : ""} @@ -649,6 +828,16 @@ export default function mount(el, ctx) { wire(); if (state.view !== "month") scrollTimegrid(); if (state.eventPopover) positionPopover(); + if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch(); + } + + function focusSearch() { + const si = el.querySelector(".cal-search"); + if (si && document.activeElement !== si) { + si.focus(); + const v = si.value; + si.setSelectionRange(v.length, v.length); + } } // Clamp the details popover fully inside the viewport (measure actual size after @@ -668,36 +857,45 @@ export default function mount(el, ctx) { } function topbar() { - const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear(); return `
- ${state.leftCollapsed ? `` : ""} -
- - ${state.menuOpen ? `
${state.apps.map((a, i) => ``).join("")}
` : ""} + ${state.leftCollapsed ? `` : ""} + -
`; } + // Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css): + // switcher + collapse/theme, primary button, an app-specific scrollable body + // (mini-month + calendars here), and the shared foot. Identical to Mail. function leftPanel() { return ` -
-
- - +
+
+ + ${state.menuOpen ? `
${state.apps.map((a, i) => ``).join("")}
` : ""} +
+
+ + +
-
- +
+
- ${miniMonth()} -
Calendars
-
${state.calendars.map((c) => ` - `).join("")}
-
- - - ${state.quota ? `
${esc(state.quota)}
` : ""} +
+ ${miniMonth()} +
Calendars
+
${state.calendars.map((c) => ` + `).join("")}
+
+
+ + + ${state.quota ? `
${esc(state.quota)}
` : ""}
`; } @@ -713,7 +911,7 @@ export default function mount(el, ctx) { return `
${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()} - +
${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `
${h}
`).join("")}${cells}
`; @@ -733,9 +931,9 @@ export default function mount(el, ctx) { - + `}
`; } @@ -746,11 +944,13 @@ export default function mount(el, ctx) { function formHtml() { const c = state.composing; const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => ``).join(""); + const defCal = c.editId ? null : state.defaultCalendar; // new events honor the default calendar + const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => ``).join(""); const chev = ``; const colorItems = COLORS.map((cc) => ``).join(""); const tz0 = c.tz || browserTz; - return `
+ const formLeft = state.leftCollapsed ? 12 : state.leftW + 12; // clear the full-height sidebar + return `

What

Where

@@ -912,9 +1112,9 @@ export default function mount(el, ctx) { const d = addDays(gs, i); const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); const cls = [d.getMonth() !== state.anchor.getMonth() ? "out" : "", sameDay(d, new Date()) ? "today" : ""].join(" "); - const label = d.getDate() === 1 ? `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getDate()}` : d.getDate(); + const mtag = d.getDate() === 1 ? `${MONTHS[d.getMonth()].slice(0, 3)} ${d.getFullYear()}` : ""; html += `
-
${label}
+
${d.getDate()}${mtag}
${dayEvs.slice(0, 4).map((ev) => `
${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}
`).join("")} ${dayEvs.length > 4 ? `
+${dayEvs.length - 4} more
` : ""}
`; @@ -976,12 +1176,31 @@ export default function mount(el, ctx) { const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn); act("menu", () => { state.menuOpen = !state.menuOpen; render(); }); + // --- search --- + on(".cal-search", "input", (e) => { + const q = e.currentTarget.value; + if (searchTimer) clearTimeout(searchTimer); + if (!q.trim()) { closeSearch(); return; } + if (!state.search) { + const today = startOfDay(new Date()); + state.search = { q, from: addDays(today, -SEARCH_WINDOW_DAYS), to: addDays(today, SEARCH_WINDOW_DAYS), results: [], loading: true }; + render(); // mounts the results panel (input re-focused in render) + } else { + state.search.q = q; + } + searchTimer = setTimeout(fetchSearch, 250); + }); + on(".cal-search", "keydown", (e) => { if (e.key === "Escape") closeSearch(); }); + // Topbar clear (the ✕ in the search box); the panel's own ✕ is wired in wireSearchPanel. + el.querySelectorAll(".cal-searchbox [data-act=search-clear]").forEach((b) => b.addEventListener("click", closeSearch)); + if (state.search) wireSearchPanel(); on("[data-app]", "click", (e) => { const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))]; state.menuOpen = false; if (a.to) ctx.navigate(a.to); else render(); }); act("settings", () => ctx.navigate("/settings")); + act("add-calendar", () => ctx.navigate("/settings/calendars?create=1")); act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); }); act("new", () => openForm(null)); act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); @@ -990,10 +1209,13 @@ export default function mount(el, ctx) { act("panel", () => { state.rightOpen = !state.rightOpen; render(); }); act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); render(); }); act("mininext", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() + 1, 1); render(); }); + act("mini-today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); // --- left panel: collapse, theme, resize (like Mail) --- - const setLeft = (v) => { state.leftCollapsed = v; try { localStorage.setItem("cal.leftCollapsed", v ? "1" : "0"); } catch { /* ignore */ } render(); }; - act("left-collapse", () => setLeft(true)); - act("left-expand", () => setLeft(false)); + act("left-toggle", () => { + state.leftCollapsed = !state.leftCollapsed; + try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ } + render(); + }); act("theme", () => { const dark = document.documentElement.classList.toggle("dark"); try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } @@ -1150,6 +1372,9 @@ export default function mount(el, ctx) { const s = await api.fetch("/settings"); HOUR12 = s && s.time_format === "12"; // 24h is the default state.jitsi = (s && s.jitsi_server) || ""; + state.defaultCalendar = (s && s.default_calendar) || ""; + state.defaultRemTimed = (s && s.default_reminders_timed) || []; + state.defaultRemAllday = (s && s.default_reminders_allday) || []; } catch { /* keep defaults (24h, public Jitsi) */ } try { const q = await api.fetch("/settings/quota"); diff --git a/screenshots/calendar-set-1.png b/screenshots/calendar-set-1.png new file mode 100644 index 0000000..d9ca650 Binary files /dev/null and b/screenshots/calendar-set-1.png differ diff --git a/screenshots/calendar-set-2.png b/screenshots/calendar-set-2.png new file mode 100644 index 0000000..6aa7e88 Binary files /dev/null and b/screenshots/calendar-set-2.png differ diff --git a/screenshots/calendar-set-3.png b/screenshots/calendar-set-3.png new file mode 100644 index 0000000..02a87ba Binary files /dev/null and b/screenshots/calendar-set-3.png differ diff --git a/screenshots/left_panel_calendar.png b/screenshots/left_panel_calendar.png new file mode 100644 index 0000000..7ced61e Binary files /dev/null and b/screenshots/left_panel_calendar.png differ diff --git a/screenshots/left_panel_mail.png b/screenshots/left_panel_mail.png new file mode 100644 index 0000000..14a6229 Binary files /dev/null and b/screenshots/left_panel_mail.png differ diff --git a/screenshots/search-1.png b/screenshots/search-1.png new file mode 100644 index 0000000..f281cc4 Binary files /dev/null and b/screenshots/search-1.png differ diff --git a/screenshots/search-2.png b/screenshots/search-2.png new file mode 100644 index 0000000..696879d Binary files /dev/null and b/screenshots/search-2.png differ diff --git a/worker-description.txt b/worker-description.txt index 7e3df1d..65fd4e7 100644 --- a/worker-description.txt +++ b/worker-description.txt @@ -3,8 +3,8 @@ How the worker functions (multi-user / multi-account) The worker wears two hats (in one process, one event loop) ---------------------------------------------------------- -It is a single arq process (`arq workers.worker.WorkerSettings`) running one -asyncio event loop. Inside it, two very different things run: +It is a single worker process (`python -m workers.run`, WORKER_ROLE=all) running +one asyncio event loop. Inside it, two very different things run: A) A job runner (the Redis queue side). The API enqueues discrete jobs; the worker pulls and runs them, up to `max_jobs = 10` concurrently,