feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants, scheduling messages, and inline calendar invitation banner in email viewer with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
getInvitationMethod,
|
||||
formatEventSummary,
|
||||
findParticipantByEmail,
|
||||
} from '../calendar-invitation';
|
||||
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 1024,
|
||||
receivedAt: '2026-02-17T10:00:00Z',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeParticipant(overrides: Partial<CalendarParticipant> = {}): CalendarParticipant {
|
||||
return {
|
||||
'@type': 'Participant',
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
description: null,
|
||||
sendTo: null,
|
||||
kind: 'individual',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
participationComment: null,
|
||||
expectReply: false,
|
||||
scheduleAgent: 'server',
|
||||
scheduleForceSend: false,
|
||||
scheduleId: null,
|
||||
scheduleSequence: 0,
|
||||
scheduleStatus: null,
|
||||
scheduleUpdated: null,
|
||||
invitedBy: null,
|
||||
delegatedTo: null,
|
||||
delegatedFrom: null,
|
||||
memberOf: null,
|
||||
locationId: null,
|
||||
language: null,
|
||||
links: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findCalendarAttachment', () => {
|
||||
it('finds attachment by MIME type text/calendar', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b1', size: 500, type: 'text/calendar', name: 'invite.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
const result = findCalendarAttachment(email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.blobId).toBe('b1');
|
||||
});
|
||||
|
||||
it('finds attachment by application/ics type', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b2', size: 500, type: 'application/ics', name: 'event.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b2');
|
||||
});
|
||||
|
||||
it('finds attachment by .ics file extension', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b3', size: 500, type: 'application/octet-stream', name: 'meeting.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b3');
|
||||
});
|
||||
|
||||
it('finds attachment by .ical file extension', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b4', size: 500, type: 'application/octet-stream', name: 'meeting.ical' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b4');
|
||||
});
|
||||
|
||||
it('returns null when no calendar attachment exists', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b5', size: 500, type: 'application/pdf', name: 'doc.pdf' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when attachments is undefined', () => {
|
||||
const email = makeEmail();
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects text/calendar in textBody parts', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
|
||||
],
|
||||
});
|
||||
const result = findCalendarAttachment(email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.blobId).toBe('tb1');
|
||||
});
|
||||
|
||||
it('prioritizes attachments over textBody', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'att1', size: 500, type: 'text/calendar', name: 'invite.ics' },
|
||||
],
|
||||
textBody: [
|
||||
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('att1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationMethod', () => {
|
||||
it('detects cancel when status is cancelled', () => {
|
||||
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
|
||||
});
|
||||
|
||||
it('detects request when participants have organizer role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, name: 'Organizer' }),
|
||||
att: makeParticipant({ roles: { attendee: true }, name: 'Attendee' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('request');
|
||||
});
|
||||
|
||||
it('detects request with chair role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { chair: true }, name: 'Chair' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('request');
|
||||
});
|
||||
|
||||
it('returns unknown when no participants', () => {
|
||||
expect(getInvitationMethod({})).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when participants have no organizer', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ roles: { attendee: true } }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
it('extracts title from event', () => {
|
||||
const summary = formatEventSummary({ title: 'Team Sync' });
|
||||
expect(summary.title).toBe('Team Sync');
|
||||
});
|
||||
|
||||
it('extracts location from event', () => {
|
||||
const summary = formatEventSummary({
|
||||
locations: { loc1: { '@type': 'Location', name: 'Room A', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||
});
|
||||
expect(summary.location).toBe('Room A');
|
||||
});
|
||||
|
||||
it('extracts organizer info', () => {
|
||||
const summary = formatEventSummary({
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, name: 'Alice', email: 'alice@example.com' }),
|
||||
att: makeParticipant({ roles: { attendee: true }, name: 'Bob' }),
|
||||
},
|
||||
});
|
||||
expect(summary.organizer).toBe('Alice');
|
||||
expect(summary.organizerEmail).toBe('alice@example.com');
|
||||
expect(summary.attendeeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles missing data gracefully', () => {
|
||||
const summary = formatEventSummary({});
|
||||
expect(summary.title).toBe('');
|
||||
expect(summary.start).toBeNull();
|
||||
expect(summary.end).toBeNull();
|
||||
expect(summary.location).toBeNull();
|
||||
expect(summary.organizer).toBeNull();
|
||||
expect(summary.attendeeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('computes end from start + duration', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-02-17T10:00:00',
|
||||
duration: 'PT1H30M',
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T10:00:00');
|
||||
expect(summary.end).toBeTruthy();
|
||||
const endDate = new Date(summary.end!);
|
||||
expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1);
|
||||
expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30);
|
||||
});
|
||||
|
||||
it('uses utcStart and utcEnd when available', () => {
|
||||
const summary = formatEventSummary({
|
||||
utcStart: '2026-02-17T15:00:00Z',
|
||||
utcEnd: '2026-02-17T16:00:00Z',
|
||||
start: '2026-02-17T10:00:00',
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T15:00:00Z');
|
||||
expect(summary.end).toBe('2026-02-17T16:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findParticipantByEmail', () => {
|
||||
it('finds participant by direct email match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com', name: 'Alice' }),
|
||||
},
|
||||
};
|
||||
const result = findParticipantByEmail(event, 'alice@example.com');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.id).toBe('p1');
|
||||
expect(result!.participant.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'Alice@Example.COM' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('finds participant by sendTo mailto', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: '', sendTo: { imip: 'mailto:bob@example.com' } }),
|
||||
},
|
||||
};
|
||||
const result = findParticipantByEmail(event, 'bob@example.com');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('returns null when no match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, 'unknown@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no participants', () => {
|
||||
expect(findParticipantByEmail({}, 'test@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with empty email', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
getParticipantCount,
|
||||
buildParticipantMap,
|
||||
} from '@/lib/calendar-participants';
|
||||
|
||||
function makeEvent(participants: Record<string, Partial<CalendarParticipant>> | null = null): CalendarEvent {
|
||||
return {
|
||||
'@type': 'Event',
|
||||
id: 'ev1',
|
||||
uid: 'uid-ev1',
|
||||
calendarIds: { cal1: true },
|
||||
title: 'Test Event',
|
||||
description: '',
|
||||
descriptionContentType: 'text/plain',
|
||||
start: '2026-03-01T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'UTC',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
recurrenceId: null,
|
||||
recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: false,
|
||||
alerts: null,
|
||||
locations: null,
|
||||
virtualLocations: null,
|
||||
links: null,
|
||||
relatedTo: null,
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
sequence: 0,
|
||||
created: '2026-03-01T09:00:00Z',
|
||||
updated: '2026-03-01T09:00:00Z',
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
participants: participants as Record<string, CalendarParticipant> | null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
};
|
||||
}
|
||||
|
||||
const orgParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
roles: { owner: true, attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:alice@example.com' },
|
||||
expectReply: false,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const attendeeParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:bob@example.com' },
|
||||
expectReply: true,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const acceptedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Carol',
|
||||
email: 'carol@example.com',
|
||||
participationStatus: 'accepted',
|
||||
};
|
||||
|
||||
const declinedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Dave',
|
||||
email: 'dave@example.com',
|
||||
participationStatus: 'declined',
|
||||
};
|
||||
|
||||
const tentativeAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Eve',
|
||||
email: 'eve@example.com',
|
||||
participationStatus: 'tentative',
|
||||
};
|
||||
|
||||
describe('isOrganizer', () => {
|
||||
it('returns true when user email matches organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with case-insensitive match', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['ALICE@EXAMPLE.COM'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user is not organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user has multiple emails and one matches', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['other@example.com', 'alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when empty user emails', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserParticipantId', () => {
|
||||
it('returns the participant ID for the user', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getUserParticipantId(event, ['bob@example.com'])).toBe('att1');
|
||||
});
|
||||
|
||||
it('returns organizer ID when user is organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBe('org');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserParticipantId(event, ['BOB@EXAMPLE.COM'])).toBe('att1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserStatus', () => {
|
||||
it('returns the participation status', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserStatus(event, ['bob@example.com'])).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('returns accepted for organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBe('accepted');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantList', () => {
|
||||
it('returns all participants as info objects', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.find(p => p.id === 'org')).toEqual({
|
||||
id: 'org',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
status: 'accepted',
|
||||
isOrganizer: true,
|
||||
});
|
||||
expect(list.find(p => p.id === 'att1')).toEqual({
|
||||
id: 'att1',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
status: 'needs-action',
|
||||
isOrganizer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantList(event)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults status to needs-action for missing status', () => {
|
||||
const event = makeEvent({
|
||||
att1: { ...attendeeParticipant, participationStatus: undefined },
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list[0].status).toBe('needs-action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusCounts', () => {
|
||||
it('counts statuses correctly', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: acceptedAttendee,
|
||||
att2: declinedAttendee,
|
||||
att3: tentativeAttendee,
|
||||
att4: attendeeParticipant,
|
||||
});
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts.accepted).toBe(2);
|
||||
expect(counts.declined).toBe(1);
|
||||
expect(counts.tentative).toBe(1);
|
||||
expect(counts['needs-action']).toBe(1);
|
||||
});
|
||||
|
||||
it('returns all zeros when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantCount', () => {
|
||||
it('returns correct count', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getParticipantCount(event)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantCount(event)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildParticipantMap', () => {
|
||||
it('creates organizer and attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[
|
||||
{ name: 'Bob', email: 'bob@example.com' },
|
||||
{ name: 'Carol', email: 'carol@example.com' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(Object.keys(map)).toHaveLength(3);
|
||||
|
||||
const org = map['organizer'];
|
||||
expect(org.name).toBe('Alice');
|
||||
expect(org.email).toBe('alice@example.com');
|
||||
expect(org.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(org.participationStatus).toBe('accepted');
|
||||
expect(org.scheduleAgent).toBe('server');
|
||||
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
|
||||
expect(org.expectReply).toBe(false);
|
||||
|
||||
const att0 = map['attendee-0'];
|
||||
expect(att0.name).toBe('Bob');
|
||||
expect(att0.email).toBe('bob@example.com');
|
||||
expect(att0.roles).toEqual({ attendee: true });
|
||||
expect(att0.participationStatus).toBe('needs-action');
|
||||
expect(att0.scheduleAgent).toBe('server');
|
||||
expect(att0.expectReply).toBe(true);
|
||||
|
||||
const att1 = map['attendee-1'];
|
||||
expect(att1.name).toBe('Carol');
|
||||
expect(att1.email).toBe('carol@example.com');
|
||||
});
|
||||
|
||||
it('creates only organizer when no attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[]
|
||||
);
|
||||
expect(Object.keys(map)).toHaveLength(1);
|
||||
expect(map['organizer']).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets @type to Participant for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p['@type']).toBe('Participant');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets kind to individual for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p.kind).toBe('individual');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getLuminance,
|
||||
isDarkColor,
|
||||
transformColorForDarkMode,
|
||||
transformBgColorForDarkMode,
|
||||
transformInlineStyles,
|
||||
} from '../color-transform';
|
||||
|
||||
@@ -149,7 +150,7 @@ describe('isDarkColor', () => {
|
||||
});
|
||||
|
||||
describe('transformColorForDarkMode', () => {
|
||||
it('should lighten very dark colors', () => {
|
||||
it('should brighten very dark colors', () => {
|
||||
const original = '#111111';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
@@ -160,11 +161,28 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should transform #333333 to a lighter color', () => {
|
||||
it('should transform #333333 to a bright color', () => {
|
||||
const transformed = transformColorForDarkMode('#333333');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
expect(luminance).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('should produce readable results for Google Calendar grays', () => {
|
||||
const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043'];
|
||||
for (const color of googleGrays) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.55);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve hue for colored text', () => {
|
||||
const transformed = transformColorForDarkMode('#1a73e8');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.b).toBeGreaterThan(rgb.r);
|
||||
expect(rgb.b).toBeGreaterThan(rgb.g);
|
||||
});
|
||||
|
||||
it('should preserve already light colors', () => {
|
||||
@@ -197,7 +215,7 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should lighten medium darkness colors', () => {
|
||||
it('should brighten medium darkness colors', () => {
|
||||
const original = '#646463';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
@@ -207,6 +225,67 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should ensure minimum contrast for all dark text colors', () => {
|
||||
const darkTextColors = [
|
||||
'#000000', '#111111', '#222222', '#333333', '#444444',
|
||||
'#555555', '#666666', '#777777', '#888888',
|
||||
];
|
||||
for (const color of darkTextColors) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformBgColorForDarkMode', () => {
|
||||
it('should darken white backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#ffffff');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should darken light gray backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#f8f9fa');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should preserve already dark backgrounds', () => {
|
||||
const darkBgs = ['#111111', '#1a1a2e', '#0f172a'];
|
||||
for (const color of darkBgs) {
|
||||
expect(transformBgColorForDarkMode(color)).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve nearly transparent backgrounds', () => {
|
||||
const original = 'rgba(255, 255, 255, 0.05)';
|
||||
expect(transformBgColorForDarkMode(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle invalid colors gracefully', () => {
|
||||
expect(transformBgColorForDarkMode('invalid')).toBe('invalid');
|
||||
expect(transformBgColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should handle rgba backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)');
|
||||
expect(transformed).toContain('rgba');
|
||||
expect(transformed).toContain('0.9');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.r).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should moderately darken medium backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#e0e0e0');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInlineStyles', () => {
|
||||
@@ -223,11 +302,23 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformed).toContain('rgb(');
|
||||
});
|
||||
|
||||
it('should transform background-color property', () => {
|
||||
const original = 'background-color: #111111';
|
||||
it('should darken light background-color property', () => {
|
||||
const original = 'background-color: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background-color:');
|
||||
const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
expect(colorMatch).not.toBeNull();
|
||||
if (colorMatch) {
|
||||
const [, r] = colorMatch.map(Number);
|
||||
expect(r).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve dark background-color unchanged', () => {
|
||||
const original = 'background-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should preserve non-color properties', () => {
|
||||
@@ -238,7 +329,7 @@ describe('transformInlineStyles', () => {
|
||||
});
|
||||
|
||||
it('should handle multiple color properties', () => {
|
||||
const original = 'color: #111111; background-color: #222222; font-weight: bold';
|
||||
const original = 'color: #111111; background-color: #ffffff; font-weight: bold';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('background-color:');
|
||||
@@ -256,7 +347,7 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should transform the James Clear email colors', () => {
|
||||
it('should brighten text colors for dark mode readability', () => {
|
||||
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
@@ -268,14 +359,14 @@ describe('transformInlineStyles', () => {
|
||||
|
||||
if (colorMatch) {
|
||||
const [, r, g, b] = colorMatch.map(Number);
|
||||
expect(r).toBeGreaterThan(51);
|
||||
expect(g).toBeGreaterThan(51);
|
||||
expect(b).toBeGreaterThan(51);
|
||||
expect(r).toBeGreaterThan(180);
|
||||
expect(g).toBeGreaterThan(180);
|
||||
expect(b).toBeGreaterThan(180);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle background shorthand with color', () => {
|
||||
const original = 'background: #333333';
|
||||
it('should darken background shorthand with color', () => {
|
||||
const original = 'background: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background:');
|
||||
@@ -293,4 +384,22 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('border-color:');
|
||||
});
|
||||
|
||||
it('should produce good contrast for Google Calendar emails', () => {
|
||||
const original = 'color: #5f6368; background-color: #f8f9fa';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
|
||||
expect(textMatch).not.toBeNull();
|
||||
expect(bgMatch).not.toBeNull();
|
||||
|
||||
if (textMatch && bgMatch) {
|
||||
const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]);
|
||||
const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
|
||||
const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05);
|
||||
expect(contrast).toBeGreaterThan(4.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Email, Attachment, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
export function findCalendarAttachment(email: Email): Attachment | null {
|
||||
if (email.attachments) {
|
||||
for (const att of email.attachments) {
|
||||
if (
|
||||
att.type === 'text/calendar' ||
|
||||
att.type === 'application/ics' ||
|
||||
att.name?.toLowerCase().endsWith('.ics') ||
|
||||
att.name?.toLowerCase().endsWith('.ical')
|
||||
) {
|
||||
return att;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (email.textBody) {
|
||||
for (const part of email.textBody) {
|
||||
if (part.type === 'text/calendar' && part.blobId) {
|
||||
return {
|
||||
partId: part.partId,
|
||||
blobId: part.blobId,
|
||||
size: part.size,
|
||||
name: part.name || 'invite.ics',
|
||||
type: 'text/calendar',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInvitationMethod(
|
||||
event: Partial<CalendarEvent>
|
||||
): 'request' | 'reply' | 'cancel' | 'unknown' {
|
||||
if (event.status === 'cancelled') {
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
if (event.participants && Object.keys(event.participants).length > 0) {
|
||||
const hasOrganizer = Object.values(event.participants).some(
|
||||
(p: CalendarParticipant) => p.roles?.owner || p.roles?.chair
|
||||
);
|
||||
if (hasOrganizer) {
|
||||
return 'request';
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export interface EventSummary {
|
||||
title: string;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
location: string | null;
|
||||
organizer: string | null;
|
||||
organizerEmail: string | null;
|
||||
attendeeCount: number;
|
||||
}
|
||||
|
||||
export function formatEventSummary(event: Partial<CalendarEvent>): EventSummary {
|
||||
let location: string | null = null;
|
||||
if (event.locations) {
|
||||
const firstLocation = Object.values(event.locations)[0];
|
||||
if (firstLocation?.name) {
|
||||
location = firstLocation.name;
|
||||
}
|
||||
}
|
||||
|
||||
let organizer: string | null = null;
|
||||
let organizerEmail: string | null = null;
|
||||
let attendeeCount = 0;
|
||||
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (p.roles?.owner || p.roles?.chair) {
|
||||
organizer = p.name || p.email || null;
|
||||
organizerEmail = p.email || null;
|
||||
}
|
||||
if (p.roles?.attendee) {
|
||||
attendeeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let end: string | null = null;
|
||||
if (event.utcEnd) {
|
||||
end = event.utcEnd;
|
||||
} else if (event.start && event.duration) {
|
||||
end = addDurationToDate(event.start, event.duration, event.timeZone);
|
||||
}
|
||||
|
||||
return {
|
||||
title: event.title || '',
|
||||
start: event.utcStart || event.start || null,
|
||||
end,
|
||||
location,
|
||||
organizer,
|
||||
organizerEmail,
|
||||
attendeeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null {
|
||||
const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const days = parseInt(match[1] || '0');
|
||||
const hours = parseInt(match[2] || '0');
|
||||
const minutes = parseInt(match[3] || '0');
|
||||
const seconds = parseInt(match[4] || '0');
|
||||
|
||||
const date = new Date(start);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
|
||||
date.setDate(date.getDate() + days);
|
||||
date.setHours(date.getHours() + hours);
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function findParticipantByEmail(
|
||||
event: Partial<CalendarEvent>,
|
||||
email: string
|
||||
): { id: string; participant: CalendarParticipant } | null {
|
||||
if (!event.participants || !email) return null;
|
||||
|
||||
const lowerEmail = email.toLowerCase();
|
||||
for (const [id, p] of Object.entries(event.participants)) {
|
||||
if (p.email?.toLowerCase() === lowerEmail) {
|
||||
return { id, participant: p };
|
||||
}
|
||||
if (p.sendTo) {
|
||||
for (const addr of Object.values(p.sendTo)) {
|
||||
if (addr.replace('mailto:', '').toLowerCase() === lowerEmail) {
|
||||
return { id, participant: p };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
export interface ParticipantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: CalendarParticipant['participationStatus'];
|
||||
isOrganizer: boolean;
|
||||
}
|
||||
|
||||
export interface StatusCounts {
|
||||
accepted: number;
|
||||
declined: number;
|
||||
tentative: number;
|
||||
'needs-action': number;
|
||||
}
|
||||
|
||||
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
|
||||
if (!event.participants) return false;
|
||||
const lower = userEmails.map(e => e.toLowerCase());
|
||||
return Object.values(event.participants).some(p =>
|
||||
p.roles?.owner && lower.includes(p.email?.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserParticipantId(event: CalendarEvent, userEmails: string[]): string | null {
|
||||
if (!event.participants) return null;
|
||||
const lower = userEmails.map(e => e.toLowerCase());
|
||||
for (const [id, p] of Object.entries(event.participants)) {
|
||||
if (lower.includes(p.email?.toLowerCase())) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUserStatus(
|
||||
event: CalendarEvent,
|
||||
userEmails: string[]
|
||||
): CalendarParticipant['participationStatus'] | null {
|
||||
if (!event.participants) return null;
|
||||
const lower = userEmails.map(e => e.toLowerCase());
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (lower.includes(p.email?.toLowerCase())) return p.participationStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getParticipantList(event: CalendarEvent): ParticipantInfo[] {
|
||||
if (!event.participants) return [];
|
||||
return Object.entries(event.participants).map(([id, p]) => ({
|
||||
id,
|
||||
name: p.name || '',
|
||||
email: p.email || '',
|
||||
status: p.participationStatus || 'needs-action',
|
||||
isOrganizer: !!p.roles?.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getStatusCounts(event: CalendarEvent): StatusCounts {
|
||||
const counts: StatusCounts = { accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 };
|
||||
if (!event.participants) return counts;
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const s = p.participationStatus || 'needs-action';
|
||||
if (s in counts) counts[s as keyof StatusCounts]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function getParticipantCount(event: CalendarEvent): number {
|
||||
if (!event.participants) return 0;
|
||||
return Object.keys(event.participants).length;
|
||||
}
|
||||
|
||||
export function buildParticipantMap(
|
||||
organizer: { name: string; email: string },
|
||||
attendees: { name: string; email: string }[]
|
||||
): Record<string, Partial<CalendarParticipant>> {
|
||||
const participants: Record<string, Partial<CalendarParticipant>> = {};
|
||||
|
||||
participants['organizer'] = {
|
||||
'@type': 'Participant',
|
||||
name: organizer.name,
|
||||
email: organizer.email,
|
||||
roles: { owner: true, attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: `mailto:${organizer.email}` },
|
||||
expectReply: false,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
attendees.forEach((a, i) => {
|
||||
participants[`attendee-${i}`] = {
|
||||
'@type': 'Participant',
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: `mailto:${a.email}` },
|
||||
expectReply: true,
|
||||
kind: 'individual',
|
||||
};
|
||||
});
|
||||
|
||||
return participants;
|
||||
}
|
||||
+27
-19
@@ -131,29 +131,37 @@ export function transformColorForDarkMode(colorString: string): string {
|
||||
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
if (luminance < 0.4) {
|
||||
const invR = 255 - rgb.r;
|
||||
const invG = 255 - rgb.g;
|
||||
const invB = 255 - rgb.b;
|
||||
if (luminance >= 0.6) return colorString;
|
||||
|
||||
const boost = 1.3;
|
||||
const r = Math.min(255, Math.round(invR * boost));
|
||||
const g = Math.min(255, Math.round(invG * boost));
|
||||
const b = Math.min(255, Math.round(invB * boost));
|
||||
const blendFactor = 0.85 - (luminance / 0.6) * 0.55;
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor));
|
||||
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor));
|
||||
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor));
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function transformBgColorForDarkMode(colorString: string): string {
|
||||
const rgb = parseColor(colorString);
|
||||
if (!rgb) return colorString;
|
||||
|
||||
if (rgb.a !== undefined && rgb.a < 0.1) {
|
||||
return colorString;
|
||||
}
|
||||
|
||||
if (luminance >= 0.4 && luminance < 0.6) {
|
||||
const factor = 1.5;
|
||||
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * factor * 0.4));
|
||||
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * factor * 0.4));
|
||||
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * factor * 0.4));
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
if (luminance < 0.2) return colorString;
|
||||
|
||||
return colorString;
|
||||
const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125);
|
||||
const darkR = 30, darkG = 31, darkB = 38;
|
||||
|
||||
const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor));
|
||||
const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor));
|
||||
const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor));
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string {
|
||||
@@ -180,7 +188,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
|
||||
if (property === 'background-color') {
|
||||
const hasImportant = value.includes('!important');
|
||||
const colorValue = value.replace('!important', '').trim();
|
||||
const transformed = transformColorForDarkMode(colorValue);
|
||||
const transformed = transformBgColorForDarkMode(colorValue);
|
||||
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
|
||||
@@ -189,7 +197,7 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
|
||||
if (colorMatch) {
|
||||
const hasImportant = value.includes('!important');
|
||||
const originalColor = colorMatch[0];
|
||||
const transformed = transformColorForDarkMode(originalColor);
|
||||
const transformed = transformBgColorForDarkMode(originalColor);
|
||||
const newValue = value.replace(originalColor, transformed);
|
||||
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user