mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -132,9 +132,13 @@ export function CalendarSidebarPanel({
|
||||
const localAccounts = useAccountStore((s) => s.accounts);
|
||||
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
// Tasks-only calendars (VTODO, no events) are kept in the store for the tasks
|
||||
// view but hidden from the event calendar list here.
|
||||
const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]);
|
||||
|
||||
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
const shared = eventCalendars.filter(c => c.isShared);
|
||||
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||
for (const cal of shared) {
|
||||
const key = cal.accountId || cal.accountName || cal.id;
|
||||
@@ -144,7 +148,7 @@ export function CalendarSidebarPanel({
|
||||
groups.get(key)!.calendars.push(cal);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
}, [eventCalendars]);
|
||||
|
||||
/**
|
||||
* Pro / multi-account grouping: every calendar bucketed by its owning
|
||||
@@ -156,7 +160,7 @@ export function CalendarSidebarPanel({
|
||||
const localAccountGroups = useMemo(() => {
|
||||
if (!multiAccountMode) return [];
|
||||
const byAccount = new Map<string, Calendar[]>();
|
||||
for (const cal of calendars) {
|
||||
for (const cal of eventCalendars) {
|
||||
const key = cal.localAccountId || '__other__';
|
||||
const list = byAccount.get(key) ?? [];
|
||||
list.push(cal);
|
||||
@@ -191,7 +195,7 @@ export function CalendarSidebarPanel({
|
||||
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
|
||||
}
|
||||
return ordered;
|
||||
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
|
||||
}, [multiAccountMode, eventCalendars, localAccounts, activeLocalAccountId, t]);
|
||||
|
||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isTaskLikeObject, findTasksOnlyCalendarIds, type ScannedCalendarObject } from '@/lib/calendar-component-detection';
|
||||
|
||||
describe('isTaskLikeObject', () => {
|
||||
it('treats an explicit @type Task as a task', () => {
|
||||
expect(isTaskLikeObject({ '@type': 'Task' })).toBe(true);
|
||||
expect(isTaskLikeObject({ '@type': 'task' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an @type Event as not a task even with a stray field', () => {
|
||||
expect(isTaskLikeObject({ '@type': 'Event' })).toBe(false);
|
||||
expect(isTaskLikeObject({ '@type': 'Event', due: '2026-01-01T00:00:00' })).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a CalDAV task lacking @type by its task-only fields', () => {
|
||||
expect(isTaskLikeObject({ due: '2026-01-01T00:00:00' })).toBe(true);
|
||||
expect(isTaskLikeObject({ progress: 'needs-action' })).toBe(true);
|
||||
expect(isTaskLikeObject({ percentComplete: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a plain object with no task markers as an event', () => {
|
||||
expect(isTaskLikeObject({})).toBe(false);
|
||||
expect(isTaskLikeObject({ '@type': 'Event', title: 'Standup' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTasksOnlyCalendarIds', () => {
|
||||
it('flags a calendar whose objects are all tasks', () => {
|
||||
const objects: ScannedCalendarObject[] = [
|
||||
{ '@type': 'Task', calendarIds: { 'cal-tasks': true } },
|
||||
{ due: '2026-01-01T00:00:00', calendarIds: { 'cal-tasks': true } },
|
||||
];
|
||||
expect([...findTasksOnlyCalendarIds(objects, ['cal-tasks'])]).toEqual(['cal-tasks']);
|
||||
});
|
||||
|
||||
it('does not flag a calendar that has at least one event', () => {
|
||||
const objects: ScannedCalendarObject[] = [
|
||||
{ '@type': 'Task', calendarIds: { 'cal-mixed': true } },
|
||||
{ '@type': 'Event', calendarIds: { 'cal-mixed': true } },
|
||||
];
|
||||
expect(findTasksOnlyCalendarIds(objects, ['cal-mixed']).size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not flag an empty calendar (no objects)', () => {
|
||||
expect(findTasksOnlyCalendarIds([], ['cal-empty']).size).toBe(0);
|
||||
const objects: ScannedCalendarObject[] = [{ '@type': 'Event', calendarIds: { 'cal-other': true } }];
|
||||
expect(findTasksOnlyCalendarIds(objects, ['cal-empty']).size).toBe(0);
|
||||
});
|
||||
|
||||
it('classifies each calendar independently in a mixed account', () => {
|
||||
const objects: ScannedCalendarObject[] = [
|
||||
{ '@type': 'Event', calendarIds: { work: true } },
|
||||
{ '@type': 'Task', calendarIds: { todos: true } },
|
||||
{ percentComplete: 50, calendarIds: { todos: true } },
|
||||
];
|
||||
const result = findTasksOnlyCalendarIds(objects, ['work', 'todos', 'empty']);
|
||||
expect([...result]).toEqual(['todos']);
|
||||
});
|
||||
|
||||
it('handles an object that belongs to several calendars', () => {
|
||||
// An event shared into two calendars keeps both off the tasks-only list.
|
||||
const objects: ScannedCalendarObject[] = [
|
||||
{ '@type': 'Event', calendarIds: { a: true, b: true } },
|
||||
{ '@type': 'Task', calendarIds: { b: true } },
|
||||
];
|
||||
expect(findTasksOnlyCalendarIds(objects, ['a', 'b']).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, unlink, writeFileSync } from "fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
@@ -6,8 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// The route consults admin-dashboard overrides (ADMIN_CONFIG_DIR, default
|
||||
// data/admin) before env vars. Point it at an empty temp dir so local admin
|
||||
// state on the developer's machine can't leak into these env-driven
|
||||
// assertions. Must happen before the first GET, because the config manager
|
||||
// singleton loads the directory once and caches it.
|
||||
// assertions. Set directly (not via vi.stubEnv) so unstubAllEnvs() between
|
||||
// tests can't clear it - the config-manager singleton caches it on first load.
|
||||
process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-config-route-'));
|
||||
|
||||
// Mock NextResponse before importing the route
|
||||
@@ -21,35 +21,37 @@ vi.mock('@/lib/logger', () => ({
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
// Env vars these tests drive. Stubbed to undefined before each test so the
|
||||
// developer's / CI's real environment (and any other test file sharing this
|
||||
// worker's process.env) can't leak in.
|
||||
const MANAGED_ENV = [
|
||||
'APP_NAME', 'NEXT_PUBLIC_APP_NAME', 'JMAP_SERVER_URL', 'NEXT_PUBLIC_JMAP_SERVER_URL',
|
||||
'OAUTH_ENABLED', 'OAUTH_CLIENT_ID', 'OAUTH_ISSUER_URL', 'SESSION_SECRET',
|
||||
'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP',
|
||||
'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME',
|
||||
'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING',
|
||||
] as const;
|
||||
|
||||
describe('config API route', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
// Unique per-test dirs so the SESSION_SECRET_FILE tests never share a path;
|
||||
// removed synchronously in afterEach (the old fire-and-forget async unlink of
|
||||
// a shared ./session-secret raced across tests under the full suite).
|
||||
const tempDirs: string[] = [];
|
||||
function secretFile(contents = 'test-secret'): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'bw-secret-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, 'session-secret');
|
||||
writeFileSync(file, contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all relevant env vars before each test
|
||||
delete process.env.APP_NAME;
|
||||
delete process.env.NEXT_PUBLIC_APP_NAME;
|
||||
delete process.env.JMAP_SERVER_URL;
|
||||
delete process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
delete process.env.OAUTH_ENABLED;
|
||||
delete process.env.OAUTH_CLIENT_ID;
|
||||
delete process.env.OAUTH_ISSUER_URL;
|
||||
delete process.env.SESSION_SECRET;
|
||||
delete process.env.SESSION_SECRET_FILE;
|
||||
delete process.env.SETTINGS_SYNC_ENABLED;
|
||||
delete process.env.STALWART_FEATURES;
|
||||
delete process.env.DEV_MOCK_JMAP;
|
||||
delete process.env.FAVICON_URL;
|
||||
delete process.env.APP_LOGO_LIGHT_URL;
|
||||
delete process.env.APP_LOGO_DARK_URL;
|
||||
delete process.env.LOGIN_COMPANY_NAME;
|
||||
delete process.env.LOGIN_IMPRINT_URL;
|
||||
delete process.env.LOGIN_PRIVACY_POLICY_URL;
|
||||
delete process.env.LOGIN_WEBSITE_URL;
|
||||
delete process.env.DOMAIN_BRANDING;
|
||||
for (const key of MANAGED_ENV) vi.stubEnv(key, undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mockRequest(headers: Record<string, string> = {}): unknown {
|
||||
@@ -93,8 +95,8 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should use runtime env vars over defaults', async () => {
|
||||
process.env.APP_NAME = 'My Mail';
|
||||
process.env.JMAP_SERVER_URL = 'https://mail.example.com';
|
||||
vi.stubEnv('APP_NAME', 'My Mail');
|
||||
vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -103,8 +105,8 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should fall back to NEXT_PUBLIC_ vars when runtime vars are unset', async () => {
|
||||
process.env.NEXT_PUBLIC_APP_NAME = 'Legacy Mail';
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL = 'https://legacy.example.com';
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'Legacy Mail');
|
||||
vi.stubEnv('NEXT_PUBLIC_JMAP_SERVER_URL', 'https://legacy.example.com');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -113,8 +115,8 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should prefer runtime vars over NEXT_PUBLIC_ vars', async () => {
|
||||
process.env.APP_NAME = 'Runtime';
|
||||
process.env.NEXT_PUBLIC_APP_NAME = 'BuildTime';
|
||||
vi.stubEnv('APP_NAME', 'Runtime');
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'BuildTime');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -122,10 +124,10 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should return login page customization values', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Acme Corp';
|
||||
process.env.LOGIN_IMPRINT_URL = 'https://acme.com/imprint';
|
||||
process.env.LOGIN_PRIVACY_POLICY_URL = 'https://acme.com/privacy';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://acme.com';
|
||||
vi.stubEnv('LOGIN_COMPANY_NAME', 'Acme Corp');
|
||||
vi.stubEnv('LOGIN_IMPRINT_URL', 'https://acme.com/imprint');
|
||||
vi.stubEnv('LOGIN_PRIVACY_POLICY_URL', 'https://acme.com/privacy');
|
||||
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://acme.com');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -136,7 +138,7 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should handle partial login customization', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Partial Corp';
|
||||
vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp');
|
||||
// Leave URLs unset
|
||||
|
||||
const config = await getConfig();
|
||||
@@ -148,55 +150,43 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should enable rememberMe when SESSION_SECRET is set', async () => {
|
||||
process.env.SESSION_SECRET = 'test-secret';
|
||||
vi.stubEnv('SESSION_SECRET', 'test-secret');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
expect(config.rememberMeEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
|
||||
writeFileSync('./session-secret', 'test-secret');
|
||||
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||
vi.stubEnv('SESSION_SECRET_FILE', secretFile());
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
unlink('./session-secret', (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
|
||||
expect(config.rememberMeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable settingsSync only when both SESSION_SECRET and SETTINGS_SYNC_ENABLED are set', async () => {
|
||||
process.env.SETTINGS_SYNC_ENABLED = 'true';
|
||||
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
|
||||
const config1 = await getConfig();
|
||||
expect(config1.settingsSyncEnabled).toBe(false);
|
||||
|
||||
process.env.SESSION_SECRET = 'test-secret';
|
||||
vi.stubEnv('SESSION_SECRET', 'test-secret');
|
||||
const config2 = await getConfig();
|
||||
expect(config2.settingsSyncEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
|
||||
process.env.SETTINGS_SYNC_ENABLED = 'true';
|
||||
vi.stubEnv('SETTINGS_SYNC_ENABLED', 'true');
|
||||
const config1 = await getConfig();
|
||||
expect(config1.settingsSyncEnabled).toBe(false);
|
||||
|
||||
writeFileSync('./session-secret', 'test-secret');
|
||||
process.env.SESSION_SECRET_FILE = './session-secret';
|
||||
|
||||
vi.stubEnv('SESSION_SECRET_FILE', secretFile());
|
||||
const config2 = await getConfig();
|
||||
|
||||
unlink('./session-secret', (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
|
||||
expect(config2.settingsSyncEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable stalwart features when explicitly set to false', async () => {
|
||||
process.env.STALWART_FEATURES = 'false';
|
||||
vi.stubEnv('STALWART_FEATURES', 'false');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -204,9 +194,9 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('should return custom favicon and app logo URLs', async () => {
|
||||
process.env.FAVICON_URL = '/branding/custom-favicon.svg';
|
||||
process.env.APP_LOGO_LIGHT_URL = '/branding/my-logo.svg';
|
||||
process.env.APP_LOGO_DARK_URL = '/branding/my-logo-white.svg';
|
||||
vi.stubEnv('FAVICON_URL', '/branding/custom-favicon.svg');
|
||||
vi.stubEnv('APP_LOGO_LIGHT_URL', '/branding/my-logo.svg');
|
||||
vi.stubEnv('APP_LOGO_DARK_URL', '/branding/my-logo-white.svg');
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -217,15 +207,15 @@ describe('config API route', () => {
|
||||
|
||||
describe('per-domain branding overrides', () => {
|
||||
it('applies overrides for the matching host', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
|
||||
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example');
|
||||
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
|
||||
{
|
||||
host: 'mail1.example.com',
|
||||
loginCompanyName: 'Brand One',
|
||||
loginWebsiteUrl: 'https://one.example',
|
||||
},
|
||||
]);
|
||||
]));
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
@@ -234,10 +224,10 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('falls through to the global value when the host has no entry', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
|
||||
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
]));
|
||||
|
||||
const config = await getConfig({ host: 'unmapped.example.com' });
|
||||
|
||||
@@ -245,11 +235,11 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('falls through field-by-field when the matching entry omits a field', async () => {
|
||||
process.env.LOGIN_COMPANY_NAME = 'Default Co';
|
||||
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
vi.stubEnv('LOGIN_COMPANY_NAME', 'Default Co');
|
||||
vi.stubEnv('LOGIN_WEBSITE_URL', 'https://default.example');
|
||||
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
]));
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com' });
|
||||
|
||||
@@ -258,9 +248,9 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('prefers X-Forwarded-Host over Host', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
|
||||
{ host: 'public.example.com', loginCompanyName: 'Public' },
|
||||
]);
|
||||
]));
|
||||
|
||||
const config = await getConfig({
|
||||
host: 'internal.example.com',
|
||||
@@ -271,9 +261,9 @@ describe('config API route', () => {
|
||||
});
|
||||
|
||||
it('strips the port from the host header before matching', async () => {
|
||||
process.env.DOMAIN_BRANDING = JSON.stringify([
|
||||
vi.stubEnv('DOMAIN_BRANDING', JSON.stringify([
|
||||
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
|
||||
]);
|
||||
]));
|
||||
|
||||
const config = await getConfig({ host: 'mail1.example.com:8443' });
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Helpers for deciding whether a calendar holds only tasks (VTODO) and so
|
||||
* should be hidden from the *event* calendar UI (#761).
|
||||
*
|
||||
* JMAP's Calendar/get on Stalwart exposes no per-calendar supported-component
|
||||
* set, so "tasks-only" has to be derived from the objects a calendar contains,
|
||||
* mirroring how task objects are already told apart from events elsewhere.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A calendar object as seen with the minimal properties we scan. The index
|
||||
* signature keeps it tolerant of the other fields the server returns (id, etc.).
|
||||
*/
|
||||
export interface ScannedCalendarObject {
|
||||
'@type'?: string;
|
||||
due?: unknown;
|
||||
progress?: unknown;
|
||||
percentComplete?: unknown;
|
||||
calendarIds?: Record<string, boolean> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this calendar object a task rather than an event? Matches an explicit
|
||||
* `@type: "Task"`, and CalDAV-created tasks (which may lack the type) by the
|
||||
* presence of RFC 8984 §5.2 Task-only keys (`due` / `progress` /
|
||||
* `percentComplete`) - a VEVENT never carries those. Mirrors the existing
|
||||
* event/task split so classification stays consistent.
|
||||
*/
|
||||
export function isTaskLikeObject(obj: ScannedCalendarObject): boolean {
|
||||
const type = obj['@type'];
|
||||
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
||||
if (type !== 'Event' && (
|
||||
('progress' in obj && typeof obj.progress === 'string') ||
|
||||
('due' in obj && obj.due != null) ||
|
||||
('percentComplete' in obj)
|
||||
)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given every object in an account and the calendar ids under consideration,
|
||||
* return the ids of calendars that hold at least one object and whose objects
|
||||
* are ALL tasks (no event). Empty calendars are deliberately NOT included -
|
||||
* a brand-new event calendar with nothing in it must stay visible.
|
||||
*/
|
||||
export function findTasksOnlyCalendarIds(
|
||||
objects: ScannedCalendarObject[],
|
||||
calendarIds: string[],
|
||||
): Set<string> {
|
||||
const withAny = new Set<string>();
|
||||
const withEvent = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
const task = isTaskLikeObject(obj);
|
||||
const ids = obj.calendarIds ? Object.keys(obj.calendarIds) : [];
|
||||
for (const id of ids) {
|
||||
withAny.add(id);
|
||||
if (!task) withEvent.add(id);
|
||||
}
|
||||
}
|
||||
const tasksOnly = new Set<string>();
|
||||
for (const id of calendarIds) {
|
||||
if (withAny.has(id) && !withEvent.has(id)) tasksOnly.add(id);
|
||||
}
|
||||
return tasksOnly;
|
||||
}
|
||||
+61
-1
@@ -5,6 +5,7 @@ import { toWildcardQuery } from "./search-utils";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
|
||||
/**
|
||||
@@ -4882,7 +4883,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||
return (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
const calendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, calendars.map((c) => c.id));
|
||||
return calendars.map((cal) => ({ ...cal, isTasksOnly: tasksOnly.has(cal.id) }));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
@@ -4891,6 +4894,61 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of the given calendars that hold only tasks and no events, so the event
|
||||
* calendar UI can hide them. Stalwart's Calendar/get exposes no
|
||||
* supported-component set, so this is derived by scanning the objects.
|
||||
*
|
||||
* Cheap in the common case and safe on the edges: each page is classified as
|
||||
* it arrives and, as soon as EVERY calendar has been seen to contain an event,
|
||||
* the scan stops (a normal event account exits after one page). It only marks
|
||||
* a calendar tasks-only after scanning ALL of that account's objects; if the
|
||||
* account has more than the scan cap, it fails OPEN (marks nothing) rather
|
||||
* than risk hiding a calendar whose events sit past the cap. Any error also
|
||||
* fails open. Empty calendars are never marked (they must stay visible).
|
||||
*/
|
||||
private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise<Set<string>> {
|
||||
if (rawCalendarIds.length === 0) return new Set();
|
||||
|
||||
const QUERY_PAGE = 500;
|
||||
const MAX_SCAN = 5000; // safety bound; beyond this we fail open
|
||||
const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds'];
|
||||
const timeZone = getUserTimeZone();
|
||||
const objects: ScannedCalendarObject[] = [];
|
||||
const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event
|
||||
|
||||
try {
|
||||
let exhausted = false;
|
||||
for (let position = 0; position < MAX_SCAN;) {
|
||||
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
|
||||
if (timeZone) queryArgs.timeZone = timeZone;
|
||||
const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing());
|
||||
if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open
|
||||
const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || [];
|
||||
if (pageIds.length === 0) { exhausted = true; break; }
|
||||
|
||||
const getResp = await this.request([
|
||||
["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"]
|
||||
], this.calendarUsing());
|
||||
const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[];
|
||||
for (const obj of list) {
|
||||
objects.push(obj);
|
||||
if (!isTaskLikeObject(obj) && obj.calendarIds) {
|
||||
for (const id of Object.keys(obj.calendarIds)) remaining.delete(id);
|
||||
}
|
||||
}
|
||||
// Every calendar has at least one event -> none can be tasks-only.
|
||||
if (remaining.size === 0) return new Set();
|
||||
if (pageIds.length < QUERY_PAGE) { exhausted = true; break; }
|
||||
position += pageIds.length;
|
||||
}
|
||||
if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing
|
||||
return findTasksOnlyCalendarIds(objects, rawCalendarIds);
|
||||
} catch {
|
||||
return new Set(); // fail open
|
||||
}
|
||||
}
|
||||
|
||||
async getAllCalendars(): Promise<Calendar[]> {
|
||||
try {
|
||||
const allCalendars: Calendar[] = [];
|
||||
@@ -4909,6 +4967,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, rawCalendars.map((c) => c.id));
|
||||
const calendars = rawCalendars.map((cal) => ({
|
||||
...cal,
|
||||
id: isPrimary ? cal.id : `${accountId}:${cal.id}`,
|
||||
@@ -4916,6 +4975,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
isShared: !isPrimary,
|
||||
isTasksOnly: tasksOnly.has(cal.id),
|
||||
}));
|
||||
allCalendars.push(...calendars);
|
||||
}
|
||||
|
||||
@@ -546,6 +546,10 @@ export interface Calendar {
|
||||
// shared calendar (see lib/shared-calendar-colors). When true, the override
|
||||
// wins over per-event colors so the whole shared calendar paints uniformly.
|
||||
colorIsLocalOverride?: boolean;
|
||||
// True when the calendar holds only tasks (VTODO) and no events, so it is
|
||||
// hidden from the event calendar UI while remaining available to the tasks
|
||||
// view (#761). Undefined means "not determined" (treated as not tasks-only).
|
||||
isTasksOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
|
||||
@@ -330,10 +330,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const calendars = await client.getAllCalendars();
|
||||
const { selectedCalendarIds } = get();
|
||||
const stillValid = reconcileSelectedIds(selectedCalendarIds, calendars);
|
||||
// Default the visible selection to event calendars only - tasks-only
|
||||
// calendars stay out of the event grid.
|
||||
const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id);
|
||||
set({
|
||||
calendars,
|
||||
isLoading: false,
|
||||
selectedCalendarIds: stillValid.length > 0 ? stillValid : calendars.map(c => c.id),
|
||||
selectedCalendarIds: stillValid.length > 0 ? stillValid : defaultSelected,
|
||||
});
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch calendars:', error);
|
||||
|
||||
Reference in New Issue
Block a user