M4 Calendar: publish public .ics links, subscriptions, multi-calendar settings

Calendar (builtin plugin):
- Publish read-only public .ics feed per calendar via vault-sealed token
  (full-detail or free/busy-only); unauthenticated GET /public/{token},
  404 on bad/absent token. Migration calendar0004 adds publish_token,
  publish_busy, subscription_url + partial unique index.
- Subscribe to external iCal (.ics) URLs (SSRF-guarded) as read-only
  calendars; CSRF-guarded POST /subscriptions.
- Settings > Calendars: create/edit/delete, publish toggles with live
  shareable link + copy, Add-account stubs (Google/iCloud/CalDAV, deferred),
  Subscriptions section; default calendar + default reminders; auto-detected
  time zone; color-dropdown contrast fix; 3x-larger edit arrow.
- Left panel: shared Mail-parity sidebar template (.wm-side-*), calendar
  search panel, font/spacing tuning, per-section "+" create shortcut.

Frontend: CalendarOut publish fields, api publish/subscribe, settings keys.
Backend: settings default_calendar + reminder lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-01 20:22:53 +02:00
co-authored by Claude Opus 4.8
parent 8c634f7967
commit e2bef3c41d
20 changed files with 1329 additions and 88 deletions
+19
View File
@@ -26,6 +26,9 @@ class SettingsOut(BaseModel):
tz: str = "UTC" tz: str = "UTC"
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/ 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] = [] allowed_image_senders: list[str] = []
@@ -34,6 +37,9 @@ class SettingsPatch(BaseModel):
tz: str | None = None tz: str | None = None
time_format: str | None = None time_format: str | None = None
jitsi_server: 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): 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} 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: def _settings_out(user: User) -> SettingsOut:
s = user.settings s = user.settings
return SettingsOut( return SettingsOut(
@@ -63,6 +73,9 @@ def _settings_out(user: User) -> SettingsOut:
tz=str(s.get("tz", "UTC")), tz=str(s.get("tz", "UTC")),
time_format=str(s.get("time_format", "24")), time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")), 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)), 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 updates["time_format"] = body.time_format
if body.jitsi_server is not None: if body.jitsi_server is not None:
updates["jitsi_server"] = body.jitsi_server.strip() 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 user.settings = updates
await db.commit() await db.commit()
return _settings_out(user) return _settings_out(user)
+8 -4
View File
@@ -157,8 +157,8 @@ cd backend && alembic upgrade head && cd .. # migrations against 5433
# Terminal 1 — API # Terminal 1 — API
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
# Terminal 2 — worker # Terminal 2 — worker (sync supervisor + arq job consumers in one process)
arq workers.worker.WorkerSettings # from backend/ cd backend && WORKER_ROLE=all python -m workers.run
# Terminal 3 — frontend # Terminal 3 — frontend
cd frontend && npm run dev # Vite on 5173, proxies /api -> 8000 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 cd backend && alembic upgrade head
api: api:
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
worker: worker: ## run both sync + jobs in one process (dev)
cd backend && arq workers.worker.WorkerSettings 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: web:
cd frontend && npm run dev cd frontend && npm run dev
test: test:
+28
View File
@@ -13,6 +13,7 @@ export const en = {
"common.edit": "Edit", "common.edit": "Edit",
"common.close": "Close", "common.close": "Close",
"common.clear": "Clear", "common.clear": "Clear",
"common.copy": "Copy",
"common.back": "Back", "common.back": "Back",
"common.next": "Next", "common.next": "Next",
"common.signOut": "Sign out", "common.signOut": "Sign out",
@@ -226,6 +227,33 @@ export const en = {
"settings.timeFormat": "Time format", "settings.timeFormat": "Time format",
"settings.timeFormat24": "24-hour (14:30)", "settings.timeFormat24": "24-hour (14:30)",
"settings.timeFormat12": "12-hour (2:30 PM)", "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.integrations": "Integrations",
"settings.jitsi": "Jitsi Meet", "settings.jitsi": "Jitsi Meet",
"settings.jitsiHint": "settings.jitsiHint":
+39 -1
View File
@@ -5,6 +5,7 @@ import type {
AccountDetail, AccountDetail,
AccountOut, AccountOut,
AttachmentOut, AttachmentOut,
CalendarOut,
DiscoverResponse, DiscoverResponse,
DraftInput, DraftInput,
DraftOut, DraftOut,
@@ -167,12 +168,49 @@ export const api = {
// --- settings --- // --- settings ---
settings: () => apiFetch<UserSettings>("/settings"), settings: () => apiFetch<UserSettings>("/settings"),
patchSettings: ( patchSettings: (
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>, patch: Partial<
Pick<
UserSettings,
| "thread_order"
| "tz"
| "time_format"
| "jitsi_server"
| "default_calendar"
| "default_reminders_timed"
| "default_reminders_allday"
>
>,
) => ) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }), apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"), quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"), folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
// --- calendars (builtin calendar plugin) ---
calendars: () => apiFetch<CalendarOut[]>("/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) --- // --- saved searches (virtual mailboxes) ---
savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"), savedSearches: () => apiFetch<SavedSearch[]>("/saved-searches"),
createSavedSearch: (name: string, query: string) => createSavedSearch: (name: string, query: string) =>
+14
View File
@@ -148,11 +148,25 @@ export interface SessionInfo {
current: boolean; 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 { export interface UserSettings {
thread_order: "asc" | "desc"; thread_order: "asc" | "desc";
tz: string; tz: string;
time_format: "12" | "24"; time_format: "12" | "24";
jitsi_server: string; jitsi_server: string;
default_calendar: string;
default_reminders_timed: number[];
default_reminders_allday: number[];
allowed_image_senders: string[]; allowed_image_senders: string[];
} }
+12 -1
View File
@@ -10,8 +10,19 @@ type Importer = (url: string) => Promise<unknown>;
const _cache = new Map<string, Promise<PluginMount>>(); const _cache = new Map<string, Promise<PluginMount>>();
// 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. // @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<PluginMount> { export function loadBundle(url: string, importer: Importer = defaultImporter): Promise<PluginMount> {
let pending = _cache.get(url); let pending = _cache.get(url);
+92 -1
View File
@@ -9,7 +9,8 @@
*/ */
.wm-root { .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)); color: rgb(var(--app-fg));
} }
@@ -126,3 +127,93 @@
font-size: 14px; font-size: 14px;
color: rgb(var(--app-muted)); 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 <body>, 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;
}
+535 -2
View File
@@ -1,17 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "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 { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import type { CalendarOut } from "../../lib/types";
import { useMe } from "../../lib/auth"; import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat"; import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot"; 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]; type Section = (typeof SECTIONS)[number];
export function Settings() { export function Settings() {
@@ -45,6 +46,7 @@ export function Settings() {
{section === "profile" && <ProfileSection />} {section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />} {section === "security" && <SecuritySection />}
{section === "accounts" && <AccountsSection />} {section === "accounts" && <AccountsSection />}
{section === "calendars" && <CalendarsSection />}
{section === "appearance" && <AppearanceSection />} {section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />} {section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts. {/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
@@ -648,3 +650,534 @@ function IntegrationsSection() {
</section> </section>
); );
} }
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 (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
checked ? "bg-app-accent" : "bg-app-hover"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
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<CalendarOut | "new" | null>(
searchParams.get("create") === "1" ? "new" : null,
);
const calendars = useQuery({ queryKey: ["calendars"], queryFn: api.calendars });
const list = calendars.data ?? [];
return (
<section className="max-w-4xl">
<div className="mb-6 flex gap-6 border-b border-app-border text-sm">
{(["calendars", "options"] as const).map((tb) => (
<button
key={tb}
onClick={() => {
setTab(tb);
setView(null);
}}
className={`-mb-px border-b-2 pb-2 ${
tab === tb
? "border-app-accent font-semibold text-app-fg"
: "border-transparent text-app-muted hover:text-app-fg"
}`}
>
{t(tb === "calendars" ? "settings.myCalendars" : "settings.calendarOptions")}
</button>
))}
</div>
{tab === "options" ? (
<CalendarOptions calendars={list} />
) : view ? (
<CalendarForm calendar={view === "new" ? null : view} onBack={() => setView(null)} />
) : (
<CalendarList calendars={list} onCreate={() => setView("new")} onEdit={setView} />
)}
</section>
);
}
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 (
<div className="space-y-4">
<h2 className="text-lg font-semibold">{t("settings.calendars")}</h2>
<button
className="flex items-center gap-2 rounded-lg bg-app-accent px-4 py-2 text-sm font-medium text-app-accent-fg hover:opacity-90"
onClick={onCreate}
>
<span className="text-lg leading-none">+</span> {t("settings.createCalendar")}
</button>
<div className="border-t border-app-border">
{calendars
.filter((c) => c.subscription_url === null)
.map((c) => (
<div key={c.id} className="flex items-center gap-3 border-b border-app-border py-3">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ background: c.color }}
/>
<button className="min-w-0 flex-1 text-left" onClick={() => onEdit(c)}>
<div className="font-medium">{c.name}</div>
<div className="text-sm text-app-muted">
{t("settings.onlyYou")}{" "}
<a
href={api.exportCalendarUrl(c.id)}
onClick={(e) => e.stopPropagation()}
className="text-app-accent hover:underline"
>
{t("settings.export")}
</a>
</div>
</button>
<button
className="px-1 text-[42px] leading-none text-app-muted hover:text-app-fg"
onClick={() => onEdit(c)}
aria-label={t("settings.editCalendar")}
>
›
</button>
</div>
))}
</div>
<div className="pt-4">
<h3 className="text-base font-semibold">{t("settings.addAccount")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.addAccountHint")}</p>
<div className="mt-3 flex flex-wrap gap-2">
{accounts.map(([label]) => (
<button
key={label}
disabled
title={t("settings.comingSoon")}
className="rounded-lg border border-app-border px-4 py-2 text-sm opacity-60"
>
{label}
</button>
))}
</div>
</div>
<div className="pt-4">
<h3 className="text-base font-semibold">{t("settings.subscriptions")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.subscriptionsHint")}</p>
{subscriptions.length > 0 && (
<div className="mt-3 border-t border-app-border">
{subscriptions.map((c) => (
<div key={c.id} className="flex items-center gap-3 border-b border-app-border py-3">
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ background: c.color }}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">{c.name}</div>
<div className="truncate text-sm text-app-muted" title={c.subscription_url ?? ""}>
{c.subscription_url}
</div>
</div>
<button
className="rounded px-3 py-1 text-sm text-red-500 hover:bg-app-hover"
onClick={() => {
void api.deleteCalendar(c.id).then(() =>
queryClient.invalidateQueries({ queryKey: ["calendars"] }),
);
}}
>
{t("common.remove")}
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex items-center gap-2">
<input
type="url"
placeholder={t("settings.subscribeUrl")}
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-3 py-2 text-sm outline-none focus:border-app-accent"
value={subUrl}
onChange={(e) => setSubUrl(e.target.value)}
/>
<button
className="shrink-0 rounded bg-app-accent px-4 py-2 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!subUrl.trim() || subscribe.isPending}
onClick={() => subscribe.mutate()}
>
{t("settings.subscribe")}
</button>
</div>
{subscribe.isError && (
<p className="mt-2 text-sm text-red-500">{t("settings.subscribeError")}</p>
)}
</div>
</div>
);
}
function CalendarForm({ calendar, onBack }: { calendar: CalendarOut | null; onBack: () => void }) {
const t = useT();
const queryClient = useQueryClient();
const [calId, setCalId] = useState<string | null>(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<string | null>(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 = (
<div className="mt-2 flex items-center gap-2 rounded bg-app-hover px-3 py-2 text-xs">
<a
href={publicUrl}
className="min-w-0 flex-1 truncate text-app-accent hover:underline"
title={publicUrl}
>
{publicUrl}
</a>
<button
type="button"
className="shrink-0 rounded border border-app-border px-2 py-0.5 hover:bg-app-surface"
onClick={() => void navigator.clipboard.writeText(publicUrl)}
>
{t("common.copy")}
</button>
</div>
);
const colorOptions: [string, string][] = CAL_COLORS.some(([, hex]) => hex === color)
? CAL_COLORS
: [["Custom", color], ...CAL_COLORS];
return (
<div>
<div className="mb-5 flex items-center gap-2 border-b border-app-border pb-3 text-sm text-app-muted">
<button className="text-app-accent hover:underline" onClick={onBack}>
{t("settings.myCalendars")}
</button>
<span>›</span>
<span className="text-app-fg">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")}
</span>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6 border-b border-app-border pb-6">
<h3 className="text-base font-semibold">
{editing ? t("settings.editCalendar") : t("settings.createCalendar")}
</h3>
<div className="flex items-end gap-4">
<div>
<span className="mb-1 block text-sm font-medium">{t("settings.color")}</span>
<div className="flex items-center gap-2 rounded border border-app-border bg-app-surface px-2 py-1.5">
<span className="inline-block h-4 w-4 rounded-full" style={{ background: color }} />
<select
className="bg-app-surface text-sm text-app-fg outline-none"
value={color}
onChange={(e) => setColor(e.target.value)}
>
{colorOptions.map(([label, hex]) => (
<option key={hex} value={hex} className="bg-app-surface text-app-fg">
{label}
</option>
))}
</select>
</div>
</div>
<label className="block flex-1">
<span className="mb-1 block text-sm font-medium">{t("settings.name")}</span>
<input
autoFocus
className="w-full rounded border border-app-accent bg-app-surface px-3 py-2 text-sm outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
</div>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 py-6">
<div>
<h3 className="text-base font-semibold">{t("settings.publish")}</h3>
<p className="mt-1 text-sm text-app-muted">{t("settings.publishHint")}</p>
</div>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.fullEventDetails")}</span>
<SettingsToggle
checked={fullDetails}
disabled={!name.trim() || setPublish.isPending}
onChange={(v) => setPublish.mutate(v ? "full" : "off")}
/>
</div>
{fullDetails && linkRow}
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("settings.freeBusy")}</span>
<SettingsToggle
checked={freeBusy}
disabled={!name.trim() || setPublish.isPending}
onChange={(v) => setPublish.mutate(v ? "busy" : "off")}
/>
</div>
{freeBusy && linkRow}
</div>
</div>
</div>
<div className="ml-[192px] flex items-center gap-2">
<button
className="rounded bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!name.trim() || save.isPending}
onClick={() => save.mutate()}
>
{t("common.save")}
</button>
<button className="rounded bg-app-hover px-4 py-1.5 text-sm hover:opacity-90" onClick={onBack}>
{t("common.cancel")}
</button>
{editing && (
<button
className="ml-auto rounded px-4 py-1.5 text-sm text-red-500 hover:bg-app-hover"
onClick={() => del.mutate()}
>
{t("common.delete")}
</button>
)}
</div>
</div>
);
}
function ReminderList({
label,
value,
onChange,
}: {
label: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useT();
return (
<div>
<span className="mb-2 block text-sm font-medium">{label}</span>
<div className="space-y-2">
{value.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<select
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm text-app-fg outline-none"
value={m}
onChange={(e) => onChange(value.map((x, idx) => (idx === i ? Number(e.target.value) : x)))}
>
{REMINDER_OPTS.map((o) => (
<option key={o} value={o} className="bg-app-surface text-app-fg">
{reminderText(o)}
</option>
))}
</select>
<button
className="px-1 text-app-muted hover:text-app-fg"
onClick={() => onChange(value.filter((_, idx) => idx !== i))}
aria-label={t("common.delete")}
>
✕
</button>
</div>
))}
<button
className="rounded bg-app-hover px-3 py-1.5 text-sm hover:opacity-90"
onClick={() => onChange([...value, 30])}
>
🔔 {t("settings.addReminder")}
</button>
</div>
</div>
);
}
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 (
<div className="space-y-8">
<div className="grid grid-cols-[160px_1fr] gap-x-8">
<h3 className="text-base font-semibold">{t("settings.calendarDisplay")}</h3>
<label className="block max-w-md">
<span className="mb-1 block text-sm font-medium">{t("settings.timezone")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.tz && s.tz !== "UTC" ? s.tz : browserTz}
onChange={(e) => patch.mutate({ tz: e.target.value })}
>
{TZ_LIST.map((z) => (
<option key={z} value={z} className="bg-app-surface text-app-fg">
{z}
</option>
))}
</select>
</label>
</div>
<div className="grid grid-cols-[160px_1fr] gap-x-8 gap-y-6">
<h3 className="text-base font-semibold">{t("settings.default")}</h3>
<div className="max-w-md space-y-6">
<label className="block">
<span className="mb-1 block text-sm font-medium">{t("settings.defaultCalendar")}</span>
<select
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none"
value={s?.default_calendar ?? ""}
onChange={(e) => patch.mutate({ default_calendar: e.target.value })}
>
<option value="" className="bg-app-surface text-app-fg">
{t("settings.firstCalendar")}
</option>
{calendars.map((c) => (
<option key={c.id} value={c.id} className="bg-app-surface text-app-fg">
{c.name}
</option>
))}
</select>
</label>
<ReminderList
label={t("settings.defaultRemindersTimed")}
value={s?.default_reminders_timed ?? []}
onChange={(v) => patch.mutate({ default_reminders_timed: v })}
/>
<ReminderList
label={t("settings.defaultRemindersAllday")}
value={s?.default_reminders_allday ?? []}
onChange={(v) => patch.mutate({ default_reminders_allday: v })}
/>
</div>
</div>
</div>
);
}
@@ -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")
@@ -8,6 +8,7 @@ is expanded at query time (see queries.py / recurrence.py). Read-only calendars
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import logging import logging
import uuid import uuid
@@ -15,7 +16,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query 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 import text
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -29,6 +30,7 @@ from webmail_plugin_calendar.schemas import (
EditFollowingIn, EditFollowingIn,
EventIn, EventIn,
EventPatch, EventPatch,
SubscribeIn,
) )
router = APIRouter() router = APIRouter()
@@ -66,6 +68,69 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
# --- calendars --------------------------------------------------------------- # --- 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") @router.get("/calendars")
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]: async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s: async with _SDK.session(user_id) as s:
@@ -73,8 +138,8 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
rows = ( rows = (
await s.execute( await s.execute(
text( text(
"SELECT id, name, color, kind, read_only FROM plg_calendar_calendars " "SELECT id, name, color, kind, read_only, publish_token, publish_busy, "
"ORDER BY name" "subscription_url FROM plg_calendar_calendars ORDER BY name"
) )
) )
).mappings().all() ).mappings().all()
@@ -85,6 +150,9 @@ async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
"color": r["color"], "color": r["color"],
"kind": r["kind"], "kind": r["kind"],
"read_only": r["read_only"], "read_only": r["read_only"],
"publish_token": r["publish_token"],
"publish_busy": r["publish_busy"],
"subscription_url": r["subscription_url"],
} }
for r in rows 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)]) @router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
async def patch_calendar( async def patch_calendar(
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
) -> dict[str, str]: ) -> dict[str, str | None]:
fields = body.model_dump(exclude_unset=True) 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"} return {"status": "noop"}
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
async with _SDK.session(user_id) as s: 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( result = await s.execute(
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"), text(f"UPDATE plg_calendar_calendars SET {', '.join(set_parts)} WHERE id = :id"),
{**fields, "id": str(calendar_id)}, params,
) )
if result.rowcount == 0: if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found") 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)]) @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"} 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 ------------------------------------------------------------------ # --- events ------------------------------------------------------------------
@router.get("/events") @router.get("/events")
@@ -144,8 +329,21 @@ async def list_events(
user_id: CurrentUserId, user_id: CurrentUserId,
start: datetime = Query(...), start: datetime = Query(...),
end: datetime = Query(...), end: datetime = Query(...),
q: str = Query(""),
) -> dict[str, object]: ) -> 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) 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: async with _SDK.session(user_id) as s:
rows = ( rows = (
await s.execute( await s.execute(
@@ -158,9 +356,9 @@ async def list_events(
"COALESCE(e.color, c.color) AS color " "COALESCE(e.color, c.color) AS color "
"FROM plg_calendar_events e " "FROM plg_calendar_events e "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id " "JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
"WHERE e.dtstart < :end" f"{where}"
), ),
{"end": range_end}, params,
) )
).mappings().all() ).mappings().all()
base = [ base = [
@@ -498,6 +696,33 @@ async def delete_event(
return {"status": "ok"} 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) -------- # --- RSVP (public: guests respond from the invitation email, no login) --------
def _rsvp_page(message: str) -> str: 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: async def bundle(filename: str) -> FileResponse:
if filename != "page.js": if filename != "page.js":
raise HTTPException(status_code=404) 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"},
)
@@ -16,6 +16,13 @@ class CalendarIn(BaseModel):
class CalendarPatch(BaseModel): class CalendarPatch(BaseModel):
name: str | None = None name: str | None = None
color: 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): class Attendee(BaseModel):
@@ -20,7 +20,12 @@ let HOUR12 = false;
// Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US. // Date order follows the time preference: 24h → en-GB (day/month/year), 12h → en-US.
const dateLocale = () => (HOUR12 ? "en-US" : "en-GB"); const dateLocale = () => (HOUR12 ? "en-US" : "en-GB");
const pad2 = (n) => String(n).padStart(2, "0"); const pad2 = (n) => String(n).padStart(2, "0");
const SIDEBAR_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>`; // Icons copied from Mail (frontend/icons/*.svg) so the two apps match exactly.
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
const COMPOSE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="m14.515.456l.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.55 1.55 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474a.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.54 1.54 0 0 1 1.19 0c.189.078.361.192.505.337m-3.322 3.008l-3.67 3.669a.2.2 0 0 0-.057.096L6.97 8.965l1.736-.496a.2.2 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28l1.122-1.122a.22.22 0 0 0 .065-.157a.22.22 0 0 0-.065-.157l-.965-.966a.22.22 0 0 0-.157-.065a.23.23 0 0 0-.157.065"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.75 1.75 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25"/></svg>`;
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾"); const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
const hhmm = (d) => d.toLocaleTimeString([], { hour: HOUR12 ? "numeric" : "2-digit", minute: "2-digit", hour12: HOUR12 }); 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). // A clock label for "HH"/"MM" honouring the 12h/24h preference (form time dropdown).
@@ -186,61 +191,82 @@ function tzEntries(current) {
} }
const CSS = ` 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-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} /* Left panel styling lives in the shared sidebar shell (.wm-side-*, plugin-ui.css),
.cal-left{flex:0 0 auto;border-right:1px solid rgb(var(--app-border));display:flex;flex-direction:column;overflow:auto} which @applies Mail's exact Tailwind utilities + pins the 16px/Lato base context. */
.cal-lefthead{display:flex;align-items:center;gap:4px;padding:8px 8px 2px} .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{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::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.cal-resizer:hover::after{background:#3b82f6} .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-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} .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-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-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{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-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-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-switch{position:relative} .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-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-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
.cal-switch>button:hover{background:rgb(var(--app-hover))} .cal-search{flex:1;min-width:0;padding:7px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
.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-seg{display:inline-flex;background:rgb(var(--app-surface));border-radius:9px;padding:3px;gap:2px} .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-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{font-size:18px;font-weight:600}
.cal-title span{color:rgb(var(--app-muted));font-weight:400} .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{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 .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:hover{background:rgb(var(--app-hover))}
.cal-mini-grid button.out{color:rgb(var(--app-faint))} .cal-mini-grid button.out{color:rgb(var(--app-faint))}
.cal-mini-grid button.today{background:#3b82f6;color:#fff} .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 */ /* month */
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-auto-rows:minmax(0,1fr);height:100%} .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:11px;font-weight:600;text-transform:uppercase;color:rgb(var(--app-faint))} .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{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 .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{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-cell.today .num{background:#3b82f6;color:#fff}
.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-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 */ /* timegrid */
.cal-tg{display:grid;grid-template-columns:56px 1fr} .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-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{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 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{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} .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{border-left:1px solid rgb(var(--app-border));position:relative}
.cal-col.today{background:rgba(59,130,246,.06)} .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-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} .cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
/* right agenda */ /* right agenda */
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))} .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 } composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
eventPopover: null, // null | { ev, rect } eventPopover: null, // null | { ev, rect }
confirm: null, // null | { ev } — delete confirmation modal confirm: null, // null | { ev } — delete confirmation modal
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
me: "", me: "",
quota: "", quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) 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 }], apps: [{ label: "Mail", to: "/mail" }, { label: "Calendar", to: null }],
}; };
@@ -406,10 +436,32 @@ export default function mount(el, ctx) {
}; };
render(); render();
if (editEv) prefillForm(editEv); if (editEv) prefillForm(editEv);
else applyDefaultReminders(state.defaultRemTimed); // new events start with the user's defaults
const first = el.querySelector("[data-f=summary]"); const first = el.querySelector("[data-f=summary]");
if (first) first.focus(); 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) { function prefillForm(ev) {
const q = (s) => el.querySelector(s); const q = (s) => el.querySelector(s);
q("[data-f=summary]").value = ev.summary || ""; q("[data-f=summary]").value = ev.summary || "";
@@ -547,6 +599,131 @@ export default function mount(el, ctx) {
await reload(); 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 `<div class="cal-srch-ev" data-srch-ev="${ev.id}" data-srch-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">
<div class="t"><b>${esc(ev.summary || "(no title)")}</b><span>${ev.all_day ? "All day" : hhmm(ev._s)}</span></div>
${ev.location ? `<div class="loc">${esc(ev.location)}</div>` : ""}
</div>`;
}
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 `<div class="cal-srch"><div class="cal-srch-hd"><span>Searching…</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div></div>`;
}
// 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 += `<div class="cal-srch-month">${MONTHS[day.date.getMonth()]} <span>${day.date.getFullYear()}</span></div>`;
}
const isToday = sameDay(day.date, new Date());
const evs = day.events.slice().sort((a, b) => a._s - b._s);
rows += `${isToday ? `<div class="cal-srch-today">TODAY</div>` : ""}
<div class="cal-srch-day">
<div class="cal-srch-dt${isToday ? " today" : ""}"><span class="dow">${DAYNAMES[(day.date.getDay() + 6) % 7].toUpperCase()}</span><span class="dnum">${day.date.getDate()}</span></div>
<div class="cal-srch-evs">${evs.length ? evs.map(srchEventHtml).join("") : `<div class="cal-srch-none">No events</div>`}</div>
</div>`;
});
return `<div class="cal-srch">
<div class="cal-srch-hd"><span>Showing results from ${esc(fromStr)}</span><button class="cal-iconbtn" data-act="search-clear" title="Close search">✕</button></div>
<div class="cal-srch-more"><button class="cal-btn" data-act="search-back">Search further back</button></div>
${rows}
<div class="cal-srch-hd" style="border:0"><span>Showing results up to ${esc(toStr)}</span></div>
<div class="cal-srch-more" style="border:0"><button class="cal-btn" data-act="search-fwd">Search further forward</button></div>
</div>`;
}
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). // Replace the Video-call button/link in place (no full re-render → keeps form input).
function fillVideo(link) { function fillVideo(link) {
if (!state.composing) return; if (!state.composing) return;
@@ -636,11 +813,13 @@ export default function mount(el, ctx) {
// ---- render ---------------------------------------------------------------- // ---- render ----------------------------------------------------------------
function render() { function render() {
el.innerHTML = `<div class="cal-app"> el.innerHTML = `<div class="cal-app">
${state.leftCollapsed ? "" : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-maincol">
${topbar()} ${topbar()}
<div class="cal-body"> <div class="cal-mainarea">
${state.leftCollapsed ? "" : `<div class="cal-left" style="width:${state.leftW}px">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div> <div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
${state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : ""} ${state.search ? `<div class="cal-right">${searchPanelHtml()}</div>` : (state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : "")}
</div>
</div> </div>
${state.composing ? formHtml() : ""} ${state.composing ? formHtml() : ""}
${state.eventPopover ? eventPopoverHtml() : ""} ${state.eventPopover ? eventPopoverHtml() : ""}
@@ -649,6 +828,16 @@ export default function mount(el, ctx) {
wire(); wire();
if (state.view !== "month") scrollTimegrid(); if (state.view !== "month") scrollTimegrid();
if (state.eventPopover) positionPopover(); 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 // Clamp the details popover fully inside the viewport (measure actual size after
@@ -668,36 +857,45 @@ export default function mount(el, ctx) {
} }
function topbar() { function topbar() {
const title = MONTHS[state.anchor.getMonth()] + " " + state.anchor.getFullYear();
return `<div class="cal-topbar"> return `<div class="cal-topbar">
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-expand" title="Show sidebar">${SIDEBAR_ICON}</button>` : ""} ${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
<div class="cal-switch"> <div class="cal-searchbox">
<button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button> <svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
${state.menuOpen ? `<div class="cal-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""} <input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear search">✕</button>` : ""}
</div> </div>
<input class="cal-search" placeholder="Search calendar"/>
</div>`; </div>`;
} }
// 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() { function leftPanel() {
return ` return `
<div class="cal-lefthead"> <div class="wm-side-head">
<div style="flex:1"></div> <div class="wm-side-switch">
<button class="cal-iconbtn" data-act="left-collapse" title="Collapse sidebar">${SIDEBAR_ICON}</button> <button data-act="menu">Calendar <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg></button>
<button class="cal-iconbtn cal-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button> ${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
</div> </div>
<div style="padding:4px 10px 10px;display:flex;gap:6px"> <div class="wm-side-headicons">
<button class="cal-btn cal-btn-primary" data-act="new">+ New event</button> <button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div> </div>
</div>
<div class="wm-side-primary">
<button data-act="new">${COMPOSE_ICON}New event</button>
</div>
<div class="wm-side-body">
${miniMonth()} ${miniMonth()}
<div class="cal-section">Calendars</div> <div class="wm-side-section cal-secrow"><span>Calendars</span><button class="cal-secadd" data-act="add-calendar" title="Create calendar">+</button></div>
<div class="cal-callist">${state.calendars.map((c) => ` <div class="wm-side-nav">${state.calendars.map((c) => `
<label><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/> <label class="wm-side-item"><input type="checkbox" data-cal="${c.id}" ${state.hidden.has(c.id) ? "" : "checked"}/>
<span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div> <span style="width:10px;height:10px;border-radius:2px;background:${esc(c.color)};display:inline-block"></span>${esc(c.name)}</label>`).join("")}</div>
<div class="cal-foot"> </div>
<button data-act="settings"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button> <div class="wm-side-foot">
<button data-act="signout"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Sign out</button> <button data-act="settings">${SETTINGS_ICON}Settings</button>
${state.quota ? `<div style="border-top:1px solid rgb(var(--app-border));margin-top:4px;padding:8px 12px 2px;font-size:13px;color:rgb(var(--app-faint))">${esc(state.quota)}</div>` : ""} <button data-act="signout">${LOGOUT_ICON}Sign out</button>
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
</div>`; </div>`;
} }
@@ -713,7 +911,7 @@ export default function mount(el, ctx) {
return `<div class="cal-mini"> return `<div class="cal-mini">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b> <b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mininext">›</button></span> <span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext">›</button></span>
</div> </div>
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div> <div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
</div>`; </div>`;
@@ -733,9 +931,9 @@ export default function mount(el, ctx) {
<button class="cal-iconbtn" data-act="prev">←</button> <button class="cal-iconbtn" data-act="prev">←</button>
<button class="cal-btn" data-act="today">Today</button> <button class="cal-btn" data-act="today">Today</button>
<button class="cal-iconbtn" data-act="next">→</button> <button class="cal-iconbtn" data-act="next">→</button>
<button class="cal-iconbtn" data-act="panel" title="Toggle side panel"> ${(state.rightOpen || state.search) ? "" : `<button class="cal-iconbtn" data-act="panel" title="Open side panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>
</button> </button>`}
</div>`; </div>`;
} }
@@ -746,11 +944,13 @@ export default function mount(el, ctx) {
function formHtml() { function formHtml() {
const c = state.composing; const c = state.composing;
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const calOpts = state.calendars.filter((x) => !x.read_only).map((x) => `<option value="${x.id}">${esc(x.name)}</option>`).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) => `<option value="${x.id}"${x.id === defCal ? " selected" : ""}>${esc(x.name)}</option>`).join("");
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`; const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join(""); const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
const tz0 = c.tz || browserTz; const tz0 = c.tz || browserTz;
return `<div class="cal-form"> const formLeft = state.leftCollapsed ? 12 : state.leftW + 12; // clear the full-height sidebar
return `<div class="cal-form" style="left:${formLeft}px">
<div class="cal-form-body"> <div class="cal-form-body">
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/> <h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
<h4>Where</h4><input type="text" data-f="location"/> <h4>Where</h4><input type="text" data-f="location"/>
@@ -912,9 +1112,9 @@ export default function mount(el, ctx) {
const d = addDays(gs, i); const d = addDays(gs, i);
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); 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 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 ? `<span class="mtag">${MONTHS[d.getMonth()].slice(0, 3)} ${d.getFullYear()}</span>` : "";
html += `<div class="cal-cell ${cls}" data-day="${iso(d)}"> html += `<div class="cal-cell ${cls}" data-day="${iso(d)}">
<div class="n">${label}</div> <div class="n"><span class="num">${d.getDate()}</span>${mtag}</div>
${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")} ${dayEvs.slice(0, 4).map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("")}
${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""} ${dayEvs.length > 4 ? `<div class="cal-ev" style="border:0;background:none;color:rgb(var(--app-muted))">+${dayEvs.length - 4} more</div>` : ""}
</div>`; </div>`;
@@ -976,12 +1176,31 @@ export default function mount(el, ctx) {
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn); const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
act("menu", () => { state.menuOpen = !state.menuOpen; render(); }); 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) => { on("[data-app]", "click", (e) => {
const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))]; const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))];
state.menuOpen = false; state.menuOpen = false;
if (a.to) ctx.navigate(a.to); else render(); if (a.to) ctx.navigate(a.to); else render();
}); });
act("settings", () => ctx.navigate("/settings")); 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("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("new", () => openForm(null)); act("new", () => openForm(null));
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); 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("panel", () => { state.rightOpen = !state.rightOpen; render(); });
act("miniprev", () => { state.mini = new Date(state.mini.getFullYear(), state.mini.getMonth() - 1, 1); 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("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) --- // --- 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-toggle", () => {
act("left-collapse", () => setLeft(true)); state.leftCollapsed = !state.leftCollapsed;
act("left-expand", () => setLeft(false)); try { localStorage.setItem("cal.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ }
render();
});
act("theme", () => { act("theme", () => {
const dark = document.documentElement.classList.toggle("dark"); const dark = document.documentElement.classList.toggle("dark");
try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } 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"); const s = await api.fetch("/settings");
HOUR12 = s && s.time_format === "12"; // 24h is the default HOUR12 = s && s.time_format === "12"; // 24h is the default
state.jitsi = (s && s.jitsi_server) || ""; 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) */ } } catch { /* keep defaults (24h, public Jitsi) */ }
try { try {
const q = await api.fetch("/settings/quota"); const q = await api.fetch("/settings/quota");
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+2 -2
View File
@@ -3,8 +3,8 @@ How the worker functions (multi-user / multi-account)
The worker wears two hats (in one process, one event loop) The worker wears two hats (in one process, one event loop)
---------------------------------------------------------- ----------------------------------------------------------
It is a single arq process (`arq workers.worker.WorkerSettings`) running one It is a single worker process (`python -m workers.run`, WORKER_ROLE=all) running
asyncio event loop. Inside it, two very different things run: 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 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, worker pulls and runs them, up to `max_jobs = 10` concurrently,